ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Security] kakao 소셜 로그인
    Spring 2023. 11. 21. 01:12

    카카오 소셜 회원가입 & 로그인

    SpringSecurity + OAuth2

    사용자 정보(이메일, 이름, 프로필 사진) 받아와서 DB에 저장하기 (H2사용)

     

     

     

    https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#:~:text=Request-,https%3A//kauth.kakao.com/oauth/authorize,-%3Fresponse_type%3Dcode%26client_id

     

    Kakao Developers

    카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

    developers.kakao.com

     

     

    https://developers.kakao.com/docs/latest/ko/kakaologin/common

     

    Kakao Developers

    카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

    developers.kakao.com

     

    < 카카오 소셜 로그인 과정 >

     

     

    https://developers.kakao.com/console/app

     

    카카오계정

     

    accounts.kakao.com

    kakao developers에서 내 애플리케이션 -> 애플리케이션 추가하기 를 진행

     

    그러면 네이티브 앱 키, REST API 키, Javascript 키, Admin 키, 앱 ID 등을 준다.

     

     

    그 다음 플랫폼에서 사이트 도메인을 등록해주는데, 우선 로컬에서 확인하기 위해 Web에 로컬 (http://localhost:8080)을 등록해줬다.

     

     

    그 다음 왼쪽 바에서 카카오 로그인 -> 활성화 설정을 ON으로 바꿔준다.

    그 다음 같은 페이지에서 밑으로 내려가면 Redirect URI가 있는데 이걸 등록해준다.

     

    나의 경우 http://localhost:8080/oauth2/code/kakao 를 우선 등록해줬다.

     

     

     

    그 다음은 보안 탭에서 Client Secret을 발급받는다. 

     

    그 다음, 밑의 활성화 상태를 사용함으로 설정한다.

     

     

    동의항목에서 이메일과 프로필 사진 등을 가져오기 위해서는 비즈앱으로 전환해야 한다.

    비즈앱으로 전환한 후, 이메일을 필수 동의, 프로필 사진으로 선택 동의로 변경해줬다.

     

     

     

    본격적으로 시작하기 전에, 큰 흐름 이해하기

     

    1. 사용자가 로그인 버튼을 클릭한다.

    2. 버튼을 누르면 프론트엔드는 백엔드의 특정 URI로 요청을 보낸다.

    3. 백엔드는 이를 처리하여 client_id, redirect_uri 등을 포함하여 카카오 인증 서버(Authorization Server)의 Auth Code 발급 URL로 Redirect 시킨다. -> 카카오 인증 서버에서 사용자에게 로그인 페이지를 제공하게 됨.

    4. 사용자가 로그인을 진행하고, 정보 사용에 동의한다.

    5. 해당 정보는 Authorization Server에 전달되며, 이후 사전에 등록한 백엔드의 Redirect URI로 Auth Code와 함께 Redirected된다.

    6. 백엔드는 @GetMapping 등으로 Redirect URI로 들어오는 요청을 처리하도록 구현한다.
        - 요청의 Query String으로부터 Code를 추출한다.
        - 해당 Code를 가지고 Access Token을 받아온다.
        - 해당 AccessToken을 통해 사용자 정보를 받아 회원가입 & 로그인 시킨다.
        - 로그인 이후 발급된 인증 정보(session 혹은 token)을 쿼리 파라미터로 추가하여 프론트엔드의 로그인 성공을 처리하는 URI로 Redirect 시킨다.

     

     

    Redirect URl를 백엔드로 설정하면 프론트는 아무런 조치를 하지 않아도 됨

    하지만, 로그인 성공 이후 Header나 Body에 token 등의 정보를 넣어주는 경우 CORS 문제가 발생하여 전달할 수 없는 상황이 발생한다.

    -> 회원가입이나 로그인 처리 완료 시, 생성되는 인증 정보(JWT, 세션 등)을 프론트에 전달하는 방법은 Query String 방식밖에 없음

    [로그인 이후 Redirect할 프론트엔드 URL]?accessToken={로그인 후 발급한 액세스 토큰(or 세션 ID)}

     

    Redirect를 사용할 수 없는 모바일 환경의 경우 이 방법으로는 회원가입과 로그인 진행 불가

    -> 반드시 Redirect URI를 프론트엔드로 설정하고 처리해야 함

     

     

     

     

     

    build.gradle

    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-security'
        implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
        implementation 'org.springframework.boot:spring-boot-starter-web'
        implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'
    
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        testImplementation 'org.springframework.security:spring-security-test'
    }

     

    spring security, oauth2 client, spring web, thymeleaf, lombok을 추가했다.

     

     

    application.yml

    spring:
      datasource:
        driver-class-name: org.h2.Driver
        url: jdbc:h2:tcp://localhost/~/security
        username: sa
        password:
      jpa:
        hibernate:
          ddl-auto: create
        properties:
          hibernate:
            format_sql: true
    
      security:
        oauth2:
          client:
            provider:
              kakao:
                authorization-uri: https://kauth.kakao.com/oauth/authorize
                token-uri: https://kauth.kakao.com/oauth/token
                user-info-uri: https://kapi.kakao.com/v2/user/me
                user-name-attribute: id
            registration:
              kakao:
                client-id: ${rest api 키 값}
                client-secret: ${client secret 값}
                redirect-uri: http://localhost:8080/login/oauth2/code/kakao
                client-authentication-method: client_secret_post
                authorization-grant-type: authorization_code
                client-name: kakao
                scope:
                  - profile_nickname
                  - profile_image
                  - account_email

    provider

    : Spring Security OAuth2의 경우 provider에 대한 정보를 구글과 페이스북, 트위터 등을 가지고 있음

      우리나라에서만 한정적으로 사용하는 네이버나 카카오 등의 서비스는 직접 등록해야 함

    - authorization-uri: 인증으로 요청하는 uri

    - token-uri: 토큰을 요청하는 uri

    - user-info-uri: 회원 정보를 가져오는 uri

    - user-name-attribute: { "id":~, "kakao_account":{~}, "properties":{~} } 카카오는 위와같이 결과를 반환해준다. 따라서 user name을 id 값으로 한다는 뜻

     

    client

    : 카카오 아이디로 로그인하기 위해 만든 애플리케이션의 정보 입력

      SpringSecurityOAuth2의 redirect uri 템플릿은 {baseUrl}/login/oauth2/code/{registrationId}형식이므로 이에 맞춰 작성

    - redirect-uri는 developer 사이트에서 설정한 redirect 주소를 넣으면 된다.

    - client-authentication-method
        업데이트 전에는 POST로 하면 됐는데, 
    Spring security 5.6 이후로 client_secret_post로 변경되었다.

    - scope: 동의 항목에서 수집하는 데이터 (필수, 선택 모두)의 ID를 넣으면 된다.

     

     

     

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

     

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>login</title>
    </head>
    <body>
    <a href="/oauth2/authorization/kakao">카카오 로그인</a>
    </body>
    </html>

    카카오 로그인을 누르면

    /oauth2/authorization/kakao

    로 이동하게 된다. oauth2 client 에서 기본으로 설정된 경로이다. (변경 가능)

     

     

    회원 정보를 DB에 저장하기 위해 JPA를 사용해서 Member 엔티티를 만들었다.

    Member는 oauthId, name, email, imageUrl, role을 갖는다.

     

    Member

    @Entity
    @Getter
    public class Member {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String oauthId;
        private String name;
        private String email;
        private String imageUrl;
    
        @Enumerated(EnumType.STRING)
        private Role role;
    
        protected Member() {
        }
    
        public Member(String oauthId, String name, String email, String imageUrl, Role role) {
            this(null, oauthId, name, email, imageUrl, role);
        }
    
        public Member(Long id, String oauthId, String name, String email, String imageUrl, Role role) {
            this.id = id;
            this.oauthId = oauthId;
            this.name = name;
            this.email = email;
            this.imageUrl = imageUrl;
            this.role = role;
        }
    
        public Member update(String name, String email, String imageUrl) {
            this.name = name;
            this.email = email;
            this.imageUrl = imageUrl;
            return this;
        }
    }

     

     

     

    Role

    public enum Role {
        ADMIN("ROLE_ADMIN"),
        USER("ROLE_USER");
    
        private final String role;
    
        Role(String role) {
            this.role = role;
        }
    
        public String getString() {
            return role;
        }
    }
    

     

     

     

    MemberProfile

    package com.example.springsecurity;
    
    import lombok.Getter;
    
    
    @Getter
    public class MemberProfile {
        private final String oauthId;
        private final String userId;
        private final String nickname;
        private final String profileImageUrl;
    
        public MemberProfile(String oauthId, String nickname, String userId, String profileImageUrl) {
            this.oauthId = oauthId;
            this.nickname = nickname;
            this.userId = userId;
            this.profileImageUrl = profileImageUrl;
        }
    
        public Member toMember() {
            return new Member(oauthId, nickname, userId, profileImageUrl, Role.USER);
        }
    }
    

     

     

    MemberRepository

    public interface MemberRepository extends JpaRepository<Member, Long> {
        Member findByOauthId(String oauthId);
    }

     

     

     

    OAuthAttributes

    public enum OAuthAttributes {
        KAKAO("kakao", attributes -> {
            Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
            Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
            return new MemberProfile(
                    String.valueOf(attributes.get("id")),
                    (String) profile.get("nickname"),
                    (String) kakaoAccount.get("email"),
                    (String) profile.get("profile_image_url")
            );
        });
    
        private final String registrationId;
        private final Function<Map<String, Object>, MemberProfile> userProfileFactory;
    
        OAuthAttributes(String registrationId,
                        Function<Map<String, Object>, MemberProfile> userProfileFactory) {
            this.registrationId = registrationId;
            this.userProfileFactory = userProfileFactory;
        }
    
        public static MemberProfile extract(String registrationId, Map<String, Object> attributes) {
            return Arrays.stream(values())
                    .filter(provider -> registrationId.equals(provider.registrationId))
                    .findFirst()
                    .orElseThrow(IllegalArgumentException::new)
                    .userProfileFactory.apply(attributes);
        }
    }
    
    • attributes에는 여러가지가 담기는데 여기서 사용하는 것은 kakao_account와 id이다.
      여기에서 id는 DB의 oauth_id로 사용된다.
      kakao_account는 profile관련 정보와 email을 가져온다.
    • extract()
      OAuthAttributes는 enum타입이다. registrationId(카카오, 네이버 등 다름)에 따라 attributes에 있는 정보들을 추출하여 memberProfile으로 반환함
      • values()
        enum의 요소들을 순서대로 배열에 리턴해줌
        이후 stream으로 만들어 provider가 일치하는 경우만 filter함
        findFirst()로 하나를 찾아주는 데 만약 일치하는 게 없다면 orElseThrow 메서드로 IllegalArgumentException 발생
        일치하는 게 존재하면 Function의 추상 메서드인 apply를 호출하여 naver, kakao등 형식에 맞춰 MemberProfile을 만들어서 반환

     

     

     

     

    OAuth2UserService

    @Service
    @RequiredArgsConstructor
    public class OAuth2UserService extends DefaultOAuth2UserService {
        private final MemberRepository memberRepository;
    
        @Override
        public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
            OAuth2User oAuth2User = super.loadUser(userRequest);  //OAuth 서비스 (카카오 등)에서 가져온 유저 정보를 담음
    
            String registrationId = userRequest.getClientRegistration().getRegistrationId();  //OAuth 서비스 이름(kakao 등)
            String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                    .getUserInfoEndpoint().getUserNameAttributeName();  //OAuth 로그인 시 키값. 카카오, 네이버, 구글 등 다르기 때문
    
            Map<String, Object> attributes = oAuth2User.getAttributes();  //OAuth2 서비스의 유저 정보들
            MemberProfile memberProfile = OAuthAttributes.extract(registrationId, attributes);
            Member member = saveOrUpdateUserProfile(memberProfile);
    
            return createDefaultOAuth2User(member, attributes, userNameAttributeName);
        }
    
        private Member saveOrUpdateUserProfile(MemberProfile memberProfile) {
            Member member = memberRepository.findByOauthId(memberProfile.getOauthId());
            if (member != null) {
                return member.update(memberProfile.getUserId(), memberProfile.getNickname()
                        , memberProfile.getProfileImageUrl());
            }
            return memberRepository.save(memberProfile.toMember());
        }
    
        private OAuth2User createDefaultOAuth2User(Member member
                , Map<String, Object> attributes, String userNameAttributeName) {
            return new DefaultOAuth2User(Collections.singletonList(new SimpleGrantedAuthority(
                    member.getRole().getString())), attributes, userNameAttributeName);
        }
    
    }

     

    • loadUser(): UserInfo 끝날 때 실행 (OAuth2 로그인 후처리 담당) - 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보내서 사용자 정보를 얻은 후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환함
      • 파라미터로 받은 정보를 통해 요청한 유저가 회원이 맞는지 확인함
      • OAuth2User를 반환하면 Spring이 Session에 자동으로 저장해줌
        -> loadUser()가 호출된다는 것은 AccessToken이 이미 정상적으로 발급되었다는 뜻
    • OAuth2User를 만들어 return 함
      OAuth2User: OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저
    • UserDetailService - UserDetails 와 DefaultOAuth2UserService - OAuth2User는 유사한 관계임
    • OAuth2User와 DefaultOAuth2UserService를 커스텀하고 Spring Bean에 등록하여 사용하여, OAuth2 로그인 후 처리할 수 있음
    • Oauth2UserRequest
      - getProviderDetails()는 제공자(Google, Kakao 등)의 세부 정보 확인 가능
      - getUserInfoEndpoint()는 엔드포인트를 식별하고 사용자 정보에 접근할 수 있는 URL 제공
          -> 보통 OAuth 2.0을 사용하여 사용자 인증 및 권한 부여를 처리할 때, 클라이언트 애플리케이션은 액세스 토큰을 얻은 후 해당 액세스 토큰을 사용하여 사용자 정보 엔드포인트에 요청을 보내 사용자에 대한 추가 정보를 가져온다.
      - getUserNameAttributeName() 은 OAuth2 로그인 진행 시 primary key 역할을 한다.
    • saveOrUpdateUserProfile
      UserProfile 객체를 기반으로 사용자를 검색하여 존재하는 경우 업데이트하고, 그렇지 않은 경우 새로운 사용자로 저장한다.
    • createDefaultOAuth2User
      User 객체와 attributes, userNameAttributeName을 사용하여 DefaultOAuth2User를 생성한다
      User 객체의 역할을 기반으로 권한을 설정한다.

     

     

     

    SecurityConfig

    
    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig {
        private final OAuth2UserService oAuth2UserService;
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            http
                    .csrf(AbstractHttpConfigurer::disable)
                    .authorizeHttpRequests(authorizeRequests
                            -> authorizeRequests.anyRequest().permitAll())
                    .oauth2Login(oauth2 -> oauth2
                            .loginPage("/login")
                            .successHandler(successHandler())
                            .userInfoEndpoint(userInfo
                            -> userInfo.userService(oAuth2UserService)));
            return http.build();
        }
    
        @Bean
        public AuthenticationSuccessHandler successHandler() {
            return ((request, response, authentication) -> {
                DefaultOAuth2User defaultOAuth2User = (DefaultOAuth2User) authentication.getPrincipal();
    
                String id = defaultOAuth2User.getAttributes().get("id").toString();
                String body = String.format("""
                        {"id":"%s"}
                        """, id);
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                response.setCharacterEncoding(StandardCharsets.UTF_8.name());
    
                PrintWriter writer = response.getWriter();
                writer.println(body);
                writer.flush();
            });
        }
    }

     

    • HttpSecurity
      • csrf
        spring security 업데이트 전에는  csrf().disable()으로 깔쌈한 코드 작성이 가능했다. 하지만.. 업데이트 이후.. 위처럼 csrf.(AbstractHttpConfigurer::disable)을 해줘야 한다. 
      • oauth2Login
        • loginPage("/login")
          로그인 페이지를  http://localhost:8080/login으로 한다는 뜻
          근데?! 기본이 /login 이기 때문에 달라지는건.. 없다.
          하지만 이 설정을 하면 스프링 시큐리티에서 제공하는 기본 깔쌈(?)한 화면은 제공되지 않는다..
          그래서 깃에는 해당 설정을 빼고 올렸다 ㅎ
        • successHandler
          spring security의 인증이 성공한 이후의 로직 처리하는 기능.
          위의 경우, attributes에 있던 id(oauth id)값을 출력한다.

     

     

     

     

     

     

     

    타다~

     

     

     

     

     

     

     

    그저 킹... 갓....

    https://ttl-blog.tistory.com/1434

     

    [Spring] 쉬운 확장이 가능한 OAuth2.0 로그인 구현(카카오, 네이버, 구글 등) (Security 사용 X)

    (전 순정 백엔드 개발자기 때문에 React는 못합니다. React 코드는 Chat GPT 시켜서 구현하였고, 대신 백엔드에 온 진심을 담하서 구현하였으니, 프론트 코드는 정말 그냥 테스트용으로만 참고해 주시

    ttl-blog.tistory.com

    https://chaewsscode.tistory.com/186

     

    [Spring Boot] OAuth 2.0 를 이용한 소셜 로그인 구현

    연결 과정 목차 yml 파일에 OAuth 클라이언트의 설정 정보 작성 SecurityConfig 수정 OAuthService 구현 OAuthAttributes 생성 build.gradle 의존성 추가 UserProfile 생성 OAuth2UserService를 구현한 CustomOAuth2UserService 구현

    chaewsscode.tistory.com

    https://ksh-coding.tistory.com/57

     

    Spring Security + JWT를 이용한 자체 Login & OAuth2 Login(구글, 네이버, 카카오) API 구현 (1) - 회원(User) 관

    들어가기 전 처음 프로젝트 진행 시, 아무것도 모르던 상태에서 처음으로 만들어야겠다고 생각이 든 기능이 로그인 기능이었습니다. 처음부터 자체 Login과 OAuth2 Login(소셜 로그인)을 같이 구현해

    ksh-coding.tistory.com

    https://velog.io/@ads0070/%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-API%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D%ED%95%98%EA%B8%B0

     

    카카오 로그인 API로 로그인 인증하기

    이전 포스팅에서 소개한 대표적인 로그인 방법 3가지 중 하나인 OAuth 2.0 방식으로 로그인을 구현해보겠다.OAuth 2.0은 소셜 로그인 방식에 주로 사용되는데 무료로 해당 API를 제공해주는 카카오 로

    velog.io

     

     

Designed by Tistory.