본문 바로가기

Dot Programming/Spring

[Spring] 스프링으로 OAuth2 로그인 구현하기1 - 구글

    스프링 시큐리티와 스프링 시큐리티 OAuth2 클라이언트

    많은 서비스에서 로그인 기능을 id/password 방식 보다는 써드파티 방식으로 구글, 네이버과 같은 소셜 로그인 기능을 사용한다. 이는 서비스적으로 고객에게 접근성이 낮다는 장점도 있지만 스프링 개발자에게도 배보다 배꼽이 커지는 경우가 있을 수 있기 때문이다.

     

    OAuth2 없다면 구현해야 할 기능

    • 로그인 시 보안
    • 회원가입 시 이메일 혹은 전화번호 인증
    • 비밀번호 찾기
    • 비밀번호 변경
    • 회원정보 변경

     

    → 해당 기능들을 모두 구글, 네이버, 카카오에 맡기고 나의 서비스 개발에 더 집중할 수 있다.

     

    SpringBoot 1.5 vs SpringBoot 2.0

    spring-security-oauth2-autoconfigure 라이브러리를 사용할 경우 스프링 부트2에서도 1.5에서 쓰던 설정을 그대로 사용할 수 있다. (참고) 새로운 방법을  쓰기보다는 기존에 안전하게 작동하던 코드를 사용하는 것이 아무래도 더 확실하므로 많은 개발자가 이 방식을 사용해 왔다.

     

    그래도 SpringBoot2 선택한 이유는 다음과 같다.

    • 스프링 팀에서 기존 1.5에서 사용되던 spring-security-oauth 프로젝트는 유지 상태로 결정했으며 더는 신규기능은 추가하지 않고 버그 수정정도만 추가될 예정, 신규 기능은 새 oauth2라이브러리에서만 지원하겠다고 선언하였다.
    • 스프링 부트용 라이브러리(starter) 출시
    • 기존에 사용되던 방식은 확장 포인트가 적절하게 오픈되어 있지 않아 직접 상속하거나 오버라이딩 해야 하고 신규 라이브러리의 경우 확장 포인트를 고려해서 설계된 상태이다.

     

    스프링 부트 1.5방식에는 url주소를 모두 명시해야 하지만 2.0방식에서는 client정보만 입력해줘도 된다. CommonOAuth2Provider라는 enum이 새롭게 추가되어 구글, 깃헙, 페이스북, 옥타(Okta)의 기본 설정값은 모두 여기서 제공한다.

    → 네이버, 카카오는 스프링에서 지원해주지 않기 때문에 직접 입력해줘야 한다. 😭

     

     

    1. 구글 서비스 등록

    1) 구글 클라우드 플랫폼에 접속한다. 링크

     

    2) 구글 플랫폼 새 프로젝트 생성 > 프로젝트 이름 입력(test-project) > 만들기

     


    3) 새 프로젝트 > API 개요로 이동 > 사용자 인증 정보 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID 클릭

     


    4) 동의 화면 구성 > User Type : 외부 > 만들기 

     


    5) OAuth 동의 화면 > 앱 이름 입력(test-project) > 사용자 지원 이메일 입력 > 개발자 연락처 정보 입력 > 저장후 계속

     

     

    6) 범위 추가 또는 삭제 클릭 > 범위는 기본값 email, profile, openid 선택  > 저장 후 계속 >테스트 사용자는 패스

     

     

    7) 사용자 인증 정보로 이동 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID 클릭

     

     

     

    8) 애플리케이션 유형 (웹 에플리케이션) 선택 > 이름 입력 > 승인된 리디렉션 URI 입력 (http://localhost:8080/login/oauth2/code/google)

     

     

     

    9) 클라이언트 ID, 클라이언트 보안 비밀번호 GET

     

    10) application-oauth.yml에 로그인 해당 정보 입력

    spring:
      security:
        oauth2:
          client:
            registration:
              google:
                client-id: 클라이언트 ID
                client-secret: 클라이언트 보안 비밀번호
                scope: profile, email

    tip) scope 기본값이 openid, profile, email인데 profile, email만 입력한 이유

    → openid라는 scope가 있으면 OpenId Provider로 인식하기 때문이다. 이렇게 되면 OpenId Provider인 서비스와 그렇지 않은 서비스(네이버/카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 한다.

     

     

     

    11) .gitignore에 application-oauth.yml을 등록

    application-xxx.yml 로 만들면  xxx라는 profile이 생성되어 이를 통해 관리할 수 있다.

    스프링 프로젝트에 해당 정보를 가져오기 위해서 application.yml에 profile.incude: oauth를 입력해주면 된다.

    spring:
      profiles:
        include: oauth

     

    2. 스프링 프로젝트 구글 로그인 연동 

    인증정보를 발급받았으니 이제 구현하기만 하면 된다.

    1) User 클래스, UserRepository 클래스생성

    사용자 정보를 담당할 도메인 클래스 User와  Repository를 생성한다.

    @Entity
    @Getter @NoArgsConstructor
    public class User extends BaseTimeEntity {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @Column(nullable = false)
        private String name;
    
        @Column(nullable = false)
        private String email;
    
        @Column
        private String picture;
    
        @Enumerated(EnumType.STRING)
        @Column(nullable = false)
        private Role role;
    
        @Builder
        public User(String name, String email, String picture, Role role){
            this.name = name;
            this.email = email;
            this.picture = picture;
            this.role = role;
        }
    
        public User update(String name, String picture){
            this.name = name;
            this.picture = picture;
    
            return this;
        }
    
        public String getRoleKey(){
            return this.role.getKey();
        }
    }
    
    public interface UserRepository extends JpaRepository<User,Long> {
        Optional<User> findByEmail(String email); // 이미 email을 통해 생성된 사용자인지 체크
    }
    

     

    2) 유저 역할 Enum 클래스 Role 생성

    각 사용자 권한을 관리할 Enum 클래스 Role을 생성한다.

    @Getter
    @RequiredArgsConstructor
    public enum Role {
        GUEST("ROLE_GUEST", "손님"),
        USER("ROLE_USER", "일반 사용자");
    
        private final String key;
        private final String title;
    }
    

     

    3-1) 스프링 시큐리티 설정 -  스프링 시큐리티 관련 의존성 추가

    spring-boot-starter-oauth2-client : 소설 로그인 등 클라리언트 입장에서 소셜 기능 구현시 필요한 의존성이다.

    compile('org.springframework.boot:spring-boot-starter-oauth2-client')

     

    3-2) SecurityConfig 클래스 생성

    package com.loosie.book.springboot.config.auth;
    
    import com.loosie.book.springboot.domain.user.Role;
    import lombok.RequiredArgsConstructor;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    
    @RequiredArgsConstructor
    @EnableWebSecurity // Spring Security 설정 활성화
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        private final CustomOAuth2UserService customOAuth2UserService;
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .csrf().disable()
                    .headers().frameOptions().disable() // h2-console 화면을 사용하기 위해 해당 옵션 disable
                    .and()
                        .authorizeRequests()// URL별 권한 권리
                        .antMatchers("/","/css/**","/images/**","/js/**","/h2-console/**").permitAll()
                        .antMatchers("/api/v1/**").hasRole(Role.USER.name()) // /api/v1/** 은 USER권한만 접근 가능
                        .anyRequest().authenticated() // anyRequest : 설정된 값들 이외 나머지 URL 나타냄, authenticated : 인증된 사용자
                    .and()
                        .logout()
                            .logoutSuccessUrl("/")
                    .and()
                        .oauth2Login()
                            .userInfoEndpoint() // oauth2 로그인 성공 후 가져올 때의 설정들
                                 // 소셜로그인 성공 시 후속 조치를 진행할 UserService 인터페이스 구현체 등록
                                .userService(customOAuth2UserService); // 리소스 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시
    
            super.configure(http);
        }
    }
    

     

    4) CustomOAuth2UserService  클래스 생성

    OAuth2UserService 사용자의 정보들을 기반으로 가입 및 정보수정, 세션 저장등의 기능을 지원해준다.

    package com.loosie.book.springboot.config.auth;
    
    import com.loosie.book.springboot.config.auth.dto.OAuthAttributes;
    import com.loosie.book.springboot.config.auth.dto.SessionUser;
    import com.loosie.book.springboot.domain.user.User;
    import com.loosie.book.springboot.domain.user.UserRepository;
    import lombok.RequiredArgsConstructor;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
    import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
    import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
    import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
    import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
    import org.springframework.security.oauth2.core.user.OAuth2User;
    import org.springframework.stereotype.Service;
    
    import javax.servlet.http.HttpSession;
    import java.util.Collections;
    
    @Service
    @RequiredArgsConstructor
    public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
        private final UserRepository userRepository;
        private final HttpSession httpSession;
    
        @Override
        public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
            OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
            OAuth2User oAuth2User = delegate.loadUser(userRequest);
    
            // OAuth2 서비스 id (구글, 카카오, 네이버)
            String registrationId = userRequest.getClientRegistration().getRegistrationId();
            // OAuth2 로그인 진행 시 키가 되는 필드 값(PK)
            String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
    
            // OAuth2UserService
            OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
            User user = saveOrUpdate(attributes);
            httpSession.setAttribute("user", new SessionUser(user)); // SessionUser (직렬화된 dto 클래스 사용)
    
            return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                    attributes.getAttributes(),
                    attributes.getNameAttributeKey());
        }
    
        // 유저 생성 및 수정 서비스 로직
        private User saveOrUpdate(OAuthAttributes attributes){
            User user = userRepository.findByEmail(attributes.getEmail())
                    .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
                    .orElse(attributes.toEntity());
            return userRepository.save(user);
        }
    }
    

     

    5) SessionUser 클래스 생성

    User클래스를 그대로 사용하면 직렬화를 구현하지 않았기 때문에 에러가 발생한다. 그래서 직렬화된 SessionUser클래스를 만들어준다.

    * User클래스에 직렬화를 하지 않는 이유는 다른 엔티티와도 연관관계가 생길 수 있는 엔티티이기 때문에 성능 이슈, 부수 효과가 발생할 확률이 높기 때문이다.

    package com.loosie.book.springboot.config.auth.dto;
    
    import com.loosie.book.springboot.domain.user.User;
    import lombok.Getter;
    
    import java.io.Serializable;
    
    /**
     * 직렬화 기능을 가진 User클래스
     */
    @Getter
    public class SessionUser implements Serializable {
        private String name;
        private String email;
        private String picture;
    
        public SessionUser(User user){
            this.name = user.getName();
            this.email = user.getEmail();
            this.picture = user.getPicture();
        }
    }

     

    6) OAuthAttributes 클래스 생성

    OAuth2UserService를 통해 가져온 네이버 OAuth2User의 attributes를 담을 클래스이다.

    package com.loosie.book.springboot.config.auth.dto;
    
    import com.loosie.book.springboot.domain.user.Role;
    import com.loosie.book.springboot.domain.user.User;
    import lombok.Builder;
    import lombok.Getter;
    
    import java.util.Map;
    
    @Getter
    public class OAuthAttributes {
        private Map<String, Object> attributes; // OAuth2 반환하는 유저 정보 Map
        private String nameAttributeKey;
        private String name;
        private String email;
        private String picture;
    
        @Builder
        public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture) {
            this.attributes = attributes;
            this.nameAttributeKey = nameAttributeKey;
            this.name = name;
            this.email = email;
            this.picture = picture;
        }
    
        public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes){
            // 여기서 네이버와 카카오 등 구분 (ofNaver, ofKakao)
    
            return ofGoogle(userNameAttributeName, attributes);
        }
    
        private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
            return OAuthAttributes.builder()
                    .name((String) attributes.get("name"))
                    .email((String) attributes.get("email"))
                    .picture((String) attributes.get("picture"))
                    .attributes(attributes)
                    .nameAttributeKey(userNameAttributeName)
                    .build();
        }
    
        public User toEntity(){
            return User.builder()
                    .name(name)
                    .email(email)
                    .picture(picture)
                    .role(Role.GUEST) // 기본 권한 GUEST
                    .build();
        }
    
    }
    

     

    7) Mustache 서버 템플릿 사용 - index.mustahce 생성 (/resources/templates)

    홈 화면에 Google Login버튼을 추가해준다. url은 authorization_url로 연결시켜줘서 누르면 구글 로그인 창으로 이동한다.

    /logout : 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL이다.

    <h1>스프링 부트로 시작하는 웹 서비스</h1>
    <div class="col-md-12">
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
                {{#userName}}
                    Logged in as: <span id="user">{{userName}}</span>
                    <a href="/logout" class="btn btn-info active" role="button">Logout</a>
                {{/userName}}
                {{^userName}}
                    <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
                {{/userName}}
            </div>
        </div>
    </div>
    

     

    8) index.controller 생성 

    package com.loosie.book.springboot.web;
    
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    
    import javax.servlet.http.HttpSession;
    
    @Controller
    @RequiredArgsConstructor
    public class IndexController {
        private final HttpSession httpSession;
    
        @GetMapping("/")
        public String index(Model model){
            model.addAttribute("posts", postsService.findAllDesc());
            
            SessionUser user = (SessionUser) httpSession.getAttribute("user");
    
            if(user != null){
                model.addAttribute("userName", user.getName());
            }
            return "index";
        }
    }
    

     

     

    웹 로그인 테스트

    1) 구글 로그인 버튼 클릭

     


    2) 구글 계정 선택 혹은 새로운 로그인

     

     

    3) 로그인 성공!

     

    여기까지 완료헀으면 이젠 카카오와 네이버는 식은죽 먹기이다. 각 서비스 api를 받아와서 해당 서비스가 제공하는 데이터에 맞게 살짝 변경만 해주면된다.

     


    [Spring] 스프링으로 OAuth2 로그인 구현하기1 - 구글

    [Spring] 스프링으로 OAuth2 로그인 구현하기2 - 네이버

    [Spring] 스프링으로 OAuth2 로그인 구현하기3 - 카카오


    ※참고

    스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 이동욱