ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Security] OAuth2 소셜 로그인 (세션 방식)
    Spring Framework 2024. 5. 17. 17:02

    목표

    • Spring Security와 OAuth2 세션 방식을 사용하여 구글, 네이버, 카카오 소셜 로그인을 구현한다.
    • 로그인 사용자 정보를 MySQL 데이터베이스에 저장한다.
    • authorization-grant-type : authorization_code 방식을 사용한다.

    참고 자료

    OAuth2.0이란?

      • 사용자가 애플리케이션에서 안전하게 다른 서비스의 리소스에 접근할 수 있도록 해주는 인증 프로토콜이다. 사용자가 자신의 로그인 정보를 공유하지 않고도 제3자 애플리케이션에 권한을 부여할 수 있게 해준다.
      • 사용자의 비밀번호를 직접 공유하지 않고도 애플리케이션이 사용자의 자원에 접근할 수 있다는 것이 주요 장점이다.

    Authorization Code Grant Type

    • OAuth2에서 사용하는 인증 방식 중 하나로 가장 많이 사용되는 인증 방식이다.
    • 사용자가 로그인에 성공하면 인증 서버는 인증 코드를 반환한다. 이 코드는 애플리케이션이 액세스 토큰을 요청하는 데 사용된다.
    • 애플리케이션이 인증 코드를 포함하여 인증 서버에 액세스 토큰을 요청한다.
    • 인증 서버는 전달 받은 인증 코드가 유효하면 애플리케이션에 액세스 토큰을 발급한다.
    • 애플리케이션은 이 액세스 토큰을 사용하여 리소스 서버로부터 사용자의 데이터를 요청할 수 있다.

    전체 동작 방식

    1. 사용자가 로그인을 요청(/oauth2/authorization/서비스명)한다.
    2. OAuth2AuthorizationRequestRedirectFilter 가 로그인 요청을 가로채어 소셜 로그인 인증 서버로 리다이렉션 시킨다.
    3. 소셜 로그인 인증 서버에 도달하면 인증 서버는 해당 서비스 로그인 페이지를 응답한다.
    4. 로그인에 성공하면 인증 서버는 지정된 리다이렉트 경로(/login/oauth2/code/서비스명)로 리다이렉트한다. 이때 인증 서버가 인증 코드를 전달해준다.
    5. OAuth2LoginAuthenticationFilter 가 리다이렉트 요청을 가로채고 인증 코드와 등록 정보를 OAuth2LoginAuthenticationProvider에게 전달한다.
    6. OAuth2LoginAuthenticationProvider는 인증 서버로부터 액세스 토큰을 발급 받고, 리소스 서버에 접근하여 사용자의 정보를 획득한다.
    7. 획득한 사용자 정보는 OAuth2User 객체로 표현되며, OAuth2UserDetails와 OAuth2UserDetailsService를 통해 세션에 저장된다.

    DB 설정 (JPA & MySQL)

    더보기

    UserEntity

    @Data
    @Entity
    public class UserEntity {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String username;
        private String email;
        private String role;
    }

     

    application.properties

    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.datasource.url=jdbc:mysql://localhost:3306/[데이터베이스명]?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
    spring.datasource.username=[사용자명]
    spring.datasource.password=[비밀번호]
    
    spring.jpa.hibernate.ddl-auto=create
    spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    

     

    UserRepository

    public interface UserRepository extends JpaRepository<UserEntity, Long> {
    
        UserEntity findByUsername(String username);
    }

    OAuth2 변수 설정

    • application.properties에 OAuth2 소셜 로그인을 위한 변수를 설정한다.
    • 그렇게 하면 OAuth2AuthorizationRequestRedirectFilter → OAuth2LoginAuthenticationFilter → OAuth2LoginAuthenticationProvider까지의 과정이 자동으로 진행된다.
    • registration : 인증 서버에서 클라이언트(애플리케이션)를 식별하고 인증하는 데 필요한 정보이기 때문에 설정이 필수적이다.
    • provider : 서비스별로 정해진 값이 존재하며 구글처럼 유명한 서비스의 경우 따로 설정해주지 않아도 된다.
    • 소셜 로그인 기능을 사용하기 앞서 각 서비스의 개발자 센터 등에서 별도의 설정이 필요하다. 이에 대한 설명은 검색을 통해 쉽게 확인할 수 있기 때문에 설명을 생략한다.

     
    Google 설정

    #registration
    spring.security.oauth2.client.registration.google.client-name=google
    spring.security.oauth2.client.registration.google.client-id=
    spring.security.oauth2.client.registration.google.client-secret=
    spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/login/oauth2/code/google
    spring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code
    spring.security.oauth2.client.registration.google.scope=profile,email
    

     
    Naver 설정

    #registration
    spring.security.oauth2.client.registration.naver.client-name=naver
    spring.security.oauth2.client.registration.naver.client-id=
    spring.security.oauth2.client.registration.naver.client-secret=
    spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver
    spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
    spring.security.oauth2.client.registration.naver.scope=name,email
    
    #provider
    spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
    spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
    spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
    spring.security.oauth2.client.provider.naver.user-name-attribute=response
    

     
    Kakao 설정

    #provider
    spring.security.oauth2.client.registration.kakao.client-name=Kakao
    spring.security.oauth2.client.registration.kakao.client-id=
    spring.security.oauth2.client.registration.kakao.client-secret=
    spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
    spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
    spring.security.oauth2.client.registration.kakao.scope=profile_nickname, account_email
    spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post
    
    #registration
    spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
    spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
    spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
    spring.security.oauth2.client.provider.kakao.user-name-attribute=id

    OAuth2Response

    • 리소스 서버로부터 사용자 정보를 전달 받을 객체를 생성한다.
    • 각 서비스마다 제공하는 데이터 형식이 다르기 때문에 인터페이스를 하나 정의하고 구현하도록 작성한다.
    public interface OAuth2Response {
    
        // 제공자 (naver, google, ...)
        String getProvider();
    
        // 제공자에서 발급해주는 아이디(번호)
        String getProviderId();
    
        String getEmail();
    
        // 사용자가 설정한 이름
        String getName();
    }
    

     
     
    GoogleResponse
    구글 데이터 형식

    더보기
    {
      "sub": "12345678901234567890",
      "name": "John Doe",
      "given_name": "John",
      "family_name": "Doe",
      "picture": "https://example.com/profile-picture.jpg",
      "email": "johndoe@example.com",
      "email_verified": true,
      "locale": "en-US"
    }
    public class GoogleResponse implements OAuth2Response {
    
        private final Map<String, Object> attribute;
    
        public GoogleResponse(Map<String, Object> attribute) {
            this.attribute = attribute;
        }
    
        @Override
        public String getProvider() {
            return "google";
        }
    
        @Override
        public String getProviderId() {
            return attribute.get("sub").toString();
        }
    
        @Override
        public String getEmail() {
            return attribute.get("email").toString();
        }
    
        @Override
        public String getName() {
            return attribute.get("name").toString();
        }
    	}
    

     
     
    NaverResponse
    네이버 데이터 형식

    더보기
    {
      "resultcode": "00",
      "message": "success",
      "response": {
        "id": "12345678",
        "nickname": "John Doe",
        "name": "John Doe",
        "email": "johndoe@example.com",
        "gender": "M",
        "age": "30-39",
        "birthday": "10-01",
        "profile_image": "https://example.com/profile-image.jpg",
        "birthyear": "1980",
        "mobile": "010-1234-5678"
      }
    }
    public class NaverResponse implements OAuth2Response {
    
        private final Map<String, Object> attribute;
    
        public NaverResponse(Map<String, Object> attribute) {
            this.attribute = (Map<String, Object>) attribute.get("response");
        }
    
        @Override
        public String getProvider() {
            return "naver";
        }
    
        @Override
        public String getProviderId() {
            return attribute.get("id").toString();
        }
    
        @Override
        public String getEmail() {
            return attribute.get("email").toString() ;
        }
    
        @Override
        public String getName() {
            return attribute.get("name").toString();
        }
    }

     
     
    KakaoResponse
    카카오 데이터 형식

    더보기
    {
     "id": 2803163587,
     "connected_at": "2023-05-23T22:59:40Z",
     "properties": {
       "nickname": "별명"
     },
     "kakao_account": {
       "profile_nickname_needs_agreement": false,
       "profile": {
         "nickname": "별명"
       },
       "has_email": true,
       "email_needs_agreement": false,
       "is_email_valid": true,
       "is_email_verified": true,
       "email": "email@naver.com"
     }
    }
    public class KakaoResponse implements OAuth2Response {
    
        private Map<String, Object> attribute;
        private Map<String, Object> kakaoAccountAttribute;
        private Map<String, Object> profileAttribute;
    
        public KakaoResponse(Map<String, Object> attribute) {
            this.attribute = attribute;
            this.kakaoAccountAttribute = (Map<String, Object>) attribute.get("kakao_account");
            this.profileAttribute = (Map<String, Object>) kakaoAccountAttribute.get("profile");
        }
    
        @Override
        public String getProvider() {
            return "kakao";
        }
    
        @Override
        public String getProviderId() {
            return attribute.get("id").toString();
        }
    
        @Override
        public String getEmail() {
            return kakaoAccountAttribute.get("email").toString();
        }
    
        @Override
        public String getName() {
            return profileAttribute.get("nickname").toString();
        }
    }

    CustomOAuth2User, CustomOAuth2UserService 구현

    CustomOAuth2User

    • 사용자 정보를 다루는 커스텀 사용자 클래스이다.
    @RequiredArgsConstructor
    public class CustomOAuth2User implements OAuth2User {
    	
        // 응답 정보를 담고 있는 객체
        private final OAuth2Response oAuth2Response;
        private final String role;
    
        @Override
        public Map<String, Object> getAttributes() {
            return null;
        }
    	
        // 권한 반환
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            Collection<GrantedAuthority> collection = new ArrayList<>();
            collection.add(new GrantedAuthority() {
                @Override
                public String getAuthority() {
                    return role;
                }
            });
            return collection;
        }
    
        @Override
        public String getName() {
            return oAuth2Response.getName();
        }
    	
        // provider + providerId
        public String getUsername() {
            return oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();
        }
    }

     
     
    CustomOAuth2UserService

    • OAuth2 인증 과정에서 사용자 정보를 불러오고 저장하는 커스텀 서비스 클래스이다.
    • 반환하는 OAuth2User 객체가 스프링 시큐리티 컨텍스트에 저장된다.
    • 이에 따라 해당 사용자는 이후 요청에서 인증된 사용자로 인식된다.
    @Service
    @RequiredArgsConstructor
    public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    
        private final UserRepository userRepository;
    	
        // OAuth2 인증 요청을 받아 사용자 정보를 불러오는 메서드
        @Override
        public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    		
            // 상위 클래스에 위임하여 사용자 정보를 불러온다.
            OAuth2User oAuth2User = super.loadUser(userRequest);
            System.out.println(oAuth2User.getAttributes());
    		
            // provider 식별
            String registrationId = userRequest.getClientRegistration().getRegistrationId();
            OAuth2Response oAuth2Response = null;
    		
            // provider에 따라 OAuth2Response의 구현체를 OAuth2Response에 저장한다.
            if (registrationId.equals("naver")) {
                oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
    
            } else if (registrationId.equals("google")) {
                oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
    
            } else if (registrationId.equals("kakao")){
                oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
    
            } else {
                return null;
            }
    
            String username = oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();
            String role = "ROLE_USER";
            
            // 기존 사용자 조회
            UserEntity existData = userRepository.findByUsername(username);
            
            // 사용자가 없으면 새로운 사용자 저장
            if (existData == null) {
                UserEntity user = new UserEntity();
    
                user.setUsername(username);
                user.setEmail(oAuth2Response.getEmail());
                user.setRole(role);
    
                userRepository.save(user);
            
            // 사용자가 있으면 기존 사용자 정보 업데이트
            } else {
                existData.setUsername(username);
                existData.setEmail(oAuth2Response.getEmail());
                role = existData.getRole();
    
                userRepository.save(existData);
            }
    
            return new CustomOAuth2User(oAuth2Response, role);
        }
    }

    SecurityFilterChain 등록

    SecurityConfig

    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig {
    
        private final CustomOAuth2UserService oAuth2UserService;
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    		
            // CSRF 보호 비활성화
            http.csrf(csrf -> csrf.disable());
    		
            // 폼 로그인 비활성화
            http.formLogin(login -> login.disable());
    		
            // HTTP Basic 인증 비활성화
            http.httpBasic(basic -> basic.disable());
    		
            // OAuth2 로그인 설정
            http.oauth2Login(oauth2 -> oauth2
                    .loginPage("/login")
                    
                    // 커스텀한 서비스 클래스를 설정
                    .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
                            .userService(oAuth2UserService)));
    
            http.authorizeHttpRequests(auth -> auth
                    .requestMatchers("/", "/oauth2/**", "/login").permitAll()
                    .anyRequest().authenticated()
    
            );
    
            return http.build();
        }
    }

    테스트


    MainController

    @Controller
    public class MainController {
    
        @GetMapping("/")
        public String mainPage() {
            return "main";
        }
    }
    

     
    main.html

    <html>
    <head>
        <meta charset="UTF-8">
        <title>Document</title>
    </head>
    <body>
    main page
    </body>
    </html>
    

    MyPageController

    • 로그인 후 접근 가능
    @Controller
    public class MyPageController {
    
        @GetMapping("/myPage")
        public String myPage() {
            return "myPage";
        }
    }
    

     
    myPage.html

    <html>
    <head>
        <meta charset="UTF-8">
        <title>Document</title>
    </head>
    <body>
    my page
    </body>
    </html>
    

    LoginController

    @Controller
    public class LoginController {
    
        @GetMapping("/login")
        public String loginPage() {
            return "login";
        }
    }
    

     
    login.html

    <html>
    <head>
        <meta charset="UTF-8">
        <title>Document</title>
    </head>
    <body>
    <h1>login page</h1>
    <hr>
    <a href="/oauth2/authorization/naver">naver login</a><br>
    <a href="/oauth2/authorization/google">google login</a><br>
    <a href="/oauth2/authorization/kakao">kakao login</a>
    </body>
    </html>
    

     


    로그인 후 DB 확인


     
    이번 실습은 한 번 따라해보는 것에 의의를 두고 진행했다. 조만간 진행될 팀 프로젝트에서 소셜 로그인 기능을 넣어보고 싶어서 공부 중이다. OAuth2에 대해 처음 공부해 보는 것이라 이해가 안 되는 부분도 있고 어려웠지만 전체적인 흐름에 대해서는 정리가 된 것 같다. 데이터베이스에 로그인한 사용자 정보가 저장되는 것까지는 확인했으니 이것을 어떻게 활용해야 하는지 좀 더 공부해봐야겠다.