스프링 시큐리티, OAuth2로 구글로그인 구현


< 스프링 시큐리티 (Spring Security) >

막강한 인증(Authentication)인가(Authorization) (권한부여)를 가진 프레임워크

  • 스프링부터 2.0에서부터 크게 변경되었다.

    • 1.5는 많이 설정하지만 2.0버전에서는 client인증정보만 입력하면 된다.

      ex)


    spring.security.oauth2.client.registration.google.client-id= 인증정보
    spring.security.oauth2.client.registration.google.client-secret=인증정보
    • CommonOAuth2Provider 라는 enum이 추가되어 구글,깃허브,페이스북,옥타의 기본설정값은 모두 여기서 제공

    • 이외에 다른 소셜 로그인(네이버,카카오 등) 은 직접 다 추가해야한다.



< 구글 서비스 등록>

구글 서비스에 신규 서비스를 생성하여 발급된 인증정보(clientId, clientSecret)을 통해 로그인 기능과 소셜 서비스 기능을 사용 할 수 있다

    1. https://console.cloud.goole.com에 가서 프로젝트 선택

    2. 새 프로젝트    

    3. 프로젝트 이름 생성

    4. API 및 서비스 > 사용자 인증정보 > 사용자 인증정보 만들기 버튼

    5. OAuth 클라이언트ID 만들기 > 동의화면구성 버튼

    6. OAuth 동의화면 탭에서 애플리케이션 이름 작성

    7. OAuth 클라이언트 ID 만들기 > 웹 어플리케이션 에 체크

    8. 승인된 리디렉션 URI 확인

    9. 저장 버튼 클릭 후 프로젝트 이름과 , 클라이언트ID, 클라이언트 보안비밀 확인



< 소스 ( 키 등록 및 User 엔티티 관련 ) >

  • src/main/resources/application-oauth.properties


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


  • application.properties 에 application-oauth.properties 설정 파일을 포함하도록 추가


    spring.profiles.include=oauth


  • 보안 파일이니까 .gitignore 에 소스 추가


    application-oauth.properties


  • domain/user/User : 사용자 정보를 담당할 도메인 (Entity)


    package com.anse.book.springboot.domain.user;

    import com.anse.book.springboot.domain.BaseTimeEntity;
    import lombok.Builder;
    import lombok.Getter;
    import lombok.NoArgsConstructor;

    import javax.persistence.*;

    @Getter
    @NoArgsConstructor
    @Entity
    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;

    // 기본적으로 JPA로 DB에 저장될때 Enum 은 int로 저장
       // 숫자로 저장되면 DB로 확인할때 값의 의미를 정확히 알지 못하므로
       // 문자열로 저장할 수 있게 함
       @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();
      }
    }


  • 각 사용자의 권한을 관리할 Enum 클래스 : domain/user/Role


    package com.anse.book.springboot.domain.user;

    import lombok.Getter;
    import lombok.RequiredArgsConstructor;

    @Getter
    @RequiredArgsConstructor
    public enum Role {
    // 스프링 시큐리티에서는 권한코드에 항상 ROLE_이 앞에 붙어야 함
       GUEST("ROLE_GUEST", "손님"),
       USER("ROLE_USER", "일반 사용자");

       private final String key;
       private final String title;
    }


  • domain/user/UserRepository : User의 CRUD Interface


    package com.anse.book.springboot.domain.user;

    import org.springframework.data.jpa.repository.JpaRepository;

    import java.util.Optional;

    public interface UserRepository extends JpaRepository<User, Long> {
     
    // 소셜 로그인으로 반환되는 값 중
    // email을 통해 이미 생성된 사용자인지, 첫가입하는 사용자인지
    // 판단하기 위한 메서드
       Optional<User> findByEmail(String email);
    }



< 소스( 스프링 시큐리티 설정) >

  • build.gradle 에 스프링 시큐리티 의존성 추가


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


  • config/auth/SecurityConfig : OAuth 라이브러리를 이용한 소셜 로그인 설정 코드


    package com.anse.book.springboot.config.auth;

    import com.anse.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;

       protected void configure(HttpSecurity http) throws Exception {
           http
                  .csrf().disable().headers().frameOptions().disable()    // 1
                  .and()
                      .authorizeRequests()    // 2
                      .antMatchers("/","/css/**","/images/**","/js/**","/h2-console/**").permitAll()  // 3
                      .antMatchers("/api/v1/**").hasRole(Role.USER.name())    // 4
                      .anyRequest().authenticated()   // 5
                  .and()
                      .logout()
                      .logoutSuccessUrl("/")                  // 6
                  .and()
                      .oauth2Login()                          // 7
                      .userInfoEndpoint()                     // 8
                      .userService(customOAuth2UserService);  // 9
           /*
            * 1. h2-console화면을 사용하기 위해 해당 옵션들을 disable
            * 2. URL별 권한 관리를 설정하는 옵션의 시작점, 선언되야만 antMatcher를 사용할 수 있다.
            * 3. permitAll()로 전체 권한 줌
            * 4. USER 권한을 가진 사람만 가능
            * 5. 나머지 URL들은 모두 인증된 사용자들에게만 허용(로그인 사용자)
            * 6. 로그아웃 성공시 "/" 주소로 이동
            * 7. Oauth2 로그인 기능에 대한 설정의 진입점
            * 8. Oauth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당
            * 9. 소셜로그인 성공시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록
            *     리소스 서버(소셜서비스)에서 사용자 정보를 가져온 상태에서
            *     추가로 진행하고 하는 기능을 명시할 수 있음
            *  
            */
           
      }
    }


  • config/auth/CustomOAuth2UserService

    • 구글 로그인 이후 가져온 사용자의 정보를 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원


    package com.anse.book.springboot.config.auth;

    import com.anse.book.springboot.config.auth.dto.OAuthAttributes;
    import com.anse.book.springboot.config.auth.dto.SessionUser;
    import com.anse.book.springboot.domain.user.User;
    import com.anse.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;

    @RequiredArgsConstructor
    @Service
    public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
       private final UserRepository userRepository;
       private final HttpSession httpSession;


       @Override
       public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
           OAuth2UserService delegate = new DefaultOAuth2UserService();
           OAuth2User oAuth2User = delegate.loadUser(userRequest);

           //서비스구분하기 위한 코드 ,ex)네이버로그인인지 구글로그인인지
           String registrationId = userRequest.getClientRegistration().getRegistrationId();

           // OAuth2 로그인 진행시 키가 되는 필드값 , primary key와 같은 의미
           // 구글은 기본적인 코드를 지원하지만 ("sub"), 네이버 카카오등은 지원하지 않는다
           // 이후 네이버,구글 로그인을 동시 지원할 때 사용
           String userNameAttributeName = userRequest.getClientRegistration()
                  .getProviderDetails()
                  .getUserInfoEndpoint()
                  .getUserNameAttributeName();


           OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

           User user = saveOrUpdate(attributes);

           // SessionUser : 세션에 사용자정보를 저장하기 위한 Dto 클래스
           httpSession.setAttribute("user", new SessionUser(user));

           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);
      }
    }


  • config/auth/dto/OAuthAttributes : DTO


    package com.anse.book.springboot.config.auth.dto;

    import com.anse.book.springboot.domain.user.Role;
    import com.anse.book.springboot.domain.user.User;
    import lombok.Builder;
    import lombok.Getter;

    import java.util.Map;

    @Getter
    public class OAuthAttributes {
       private Map<String, Object> attributes;
       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;
      }

       // of() : OAuth2User에서 반환하는 사용자정보는 Map이라서 값을 하나하나 변환해야 한다
       public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
           return ofGoole(userNameAttributeName, attributes);
      }

       private static OAuthAttributes ofGoole(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();
      }

       // 첫 가입 시점에 User 엔티티를 생성
       public User toEntity() {
           return User.builder()
                  .name(name)
                  .email(email)
                  .picture(picture)
                  .role(Role.GUEST)   //가입할때 기본원한을 GUEST
                  .build();
      }
    }


  • config/auth/dto/SessionUser : 인증된 사용자 정보

    • User 클래스는 엔티티 클래스로 직렬화를 구현하지 않았다

    • 그래서 직렬화기능을 가진 세션 Dto를 하나 추가로 만드는 것이 운영,유지보수에 좋다


    package com.anse.book.springboot.config.auth.dto;

    import com.anse.book.springboot.domain.user.User;
    import lombok.Getter;

    import java.io.Serializable;

    @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();
      }
    }



< 소스 (로그인 테스트) >

  • web/IndexController


    import javax.servlet.http.HttpSession;

    @RequiredArgsConstructor
    @Controller
    public class IndexController {
       private final PostsService postsService;
       private final HttpSession httpSession;

       @GetMapping("/")
       public String index(Model model) {
           model.addAttribute("posts", postsService.findAllDesc());
         
        // CustomOAuth2UserService에서 로그인 성공시 세션에 SessionUser를 저장
           // 즉, 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있다
           SessionUser user = (SessionUser) httpSession.getAttribute("user");

          // 세션에 값이 있을때만 model 에 userName 등록
           if(user != null) {
               model.addAttribute("userName", user.getName());
          }
           return "index";
      }
    }


  • resoources/templates/index.mustache


           <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>
    • {{#userName}}

      • 머스테치는 if를 제공하지 않는다. true/false 여부만 판단할 뿐

      • 그래서 {{#userName}} 으로 값이 있을 때만 노출

    • {{^userName}}

      • 해당값이 존재하지 않는 경우 안에 있는 태그 노출

    • a href="logout"

      • 스프링 시큐리티에서 기본적으로 제공하는 로그아웃URL

      • 개발할 필요 없다 (SecurityConfig에서 URL을 변경할 수 도 있다)

    • a href = "/oauth2/authorization/google"

      • 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL

      • logout과 마찬가지


+ Recent posts