구글 로그인 연동하기

박찬미·2022년 2월 1일
0

이전에 구글 로그인 인증정보를 발급 받았으니 프로젝트에 적용해보겠다.

  • User.java 생성
    사용자 정보를 담당할 도메인
    원래 domain 패키지 아래에 user 패키지를 생성하고 그 안에 User 클래스를 생성한다.
package com.chanmi.book.springboot.domain.user;

import com.chanmi.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로 데이터베이스를 저장할 때 Enum 값을 어떤 형태로 저장할지 결정
    //기본적으로 int로 저장되지만 이러면 나중에 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수 없다.
    //그래서 문자열로 저장될 수 있도록 선언
    @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();
    }
}
  • Role
    각 사용자의 권한을 관리할 Enum 클래스
    같은 위치에 생성
package com.chanmi.book.springboot.domain.user;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;
}

스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 한다.

  • UserRepository
    User의 CRUD를 담당, 같은 패키지 안에 생성
package com.chanmi.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);
}

User 엔티티 관련 코드 모두 작성 완료!



스프링 시큐리티 설정

  • build.gradle 의존성 추가
    소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성
    spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.

빌드 완료했고, 이제 OAuth 라이브러리를 이용한 소셜 로그인 설정 코드 작성한다.

먼저 프로젝트 맨 위에(domain과 같은 위치) config.auth 패키지 생성
시큐리티 관련 클래스는 모두 이곳에..

그리고 config.auth.dto도 생성

  • SessionUser
    SessionUser에는 인증된 사용자 정보만 필요하기 때문에 name, email, picture만 필드로 선언한다.
  • User 클래스를 사용하지 않고 SessionUser를 만들어서 사용한 이유는?
    User 클래스를 세션에 저장하려고 하면 User 클래스를 직렬화 구현하지 않았다고 에러가 뜬다.

  • 그럼 User 클래스를 직렬화하면 되잖아?
    User 클래스는 엔티티이기 때문에 언제 다른 엔티티와 관계까 형성될지 모른다.
    예를 들어 @OneToMany, @ManyToMany 등 자식 엔티티를 갖고 있다면 직렬화 대상에 자식까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높다.


    ==> 그래서 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 게 운영 및 유지보수 때 좋다.


- OAuthAttributes.java
package com.chanmi.book.springboot.config.auth.dto;

import com.chanmi.book.springboot.domain.user.Role;
import com.chanmi.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;
    }

    //OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야만 한다.
    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes){

        return ofGoogle(userNameAttributeName, attributes);
    }

    public 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();
    }
    //User 엔티티를 생성한다.
    //OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때
    //가입할 때 기본 권한을 GUEST로 주기 위해 ROLE 빌더 값에는 Role.GUEST 사용
    public User toEntity(){
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}



  • CustomOAuth2UserService.java
    구글 로그인 이후 가져온 사용자의 정보(email, name, picture 등)들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원
package com.chanmi.book.springboot.config.auth;

import com.chanmi.book.springboot.config.auth.dto.OAuthAttributes;
import com.chanmi.book.springboot.config.auth.dto.SessionUser;
import com.chanmi.book.springboot.domain.user.User;
import com.chanmi.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<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        //현재 로그인 진행 중인 서비스를 구분하는 코드
        //구글만 연동했을 때는 구글만 사용하므로 불필요한 값이지만, 이후 네이버 로그인 연동이나 다른 연동 더하면 구글인지 네이버인지 구분
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        //OAuth2 로그인 진행 시 키가 되는 필드 값, Primary Key와 같은 의미
        //구글의 경우 기본적으로 코드 지원하지만, 네이버/카카오 등은 기본 지원 안함(구글 기본코드 : "sub")
        //이후 네이버 로그인과 구글 로그인 동시 지원할 때 사용
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                                        .getUserInfoEndpoint().getUserNameAttributeName();

        //OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
        //이후 네이버 등 다른 소셜 로그인도 이 클래스 사용
        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());
    }
    
   //구글 사용자 정보가 업데이트 되었을 때 대비해 구현
   //사용자의 이름, 프로필 사진이 변경되면 User 엔티티에도 반영 
   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);
   }
}



  • SecurityConfig.java
package com.chanmi.book.springboot.config.auth;

import com.chanmi.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
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOauth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception{
        http
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "h2-console/**").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout()
                        .logoutSuccessUrl("/")
                .and()
                    .oauth2Login()
                        .userInfoEndpoint()
                            .userService(customOauth2UserService);
    }
}
  1. @EnableWebSecurity
    Spring Security 설정들 활성화
  2. csrf().disable().headers().frameOptions().disable()
    h2-console 화면을 사용하기 위해 해당 옵션들을 disable
  3. authorizeRequests
    URL별 권한 관리를 설정하는 옵션의 시작점
    authorizeRequests가 선언되어야만 antMatchers 옵션을 사용 가능
  4. antMatchers
    권한 관리 대상을 지정하는 옵션
    URL, HTTP 메소드별로 관리 가능
    "/"등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한 줌
    "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 함
  5. anyRequest
    설정된 값들 이외 나머지 URL들 나타냄
  6. logout().logoutSuccessUrl("/")
    로그아웃 기능에 대한 여러 설정의 진입점
    로그아웃 성공 시 /로 이동
  7. oauth2Login
    OAuth2 로그인 기능에 대한 여러 설정의 진입점
  8. userInfoEndpoint
    OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당
  9. userService
    소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록
    리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시 가능



로그인 테스트

로그인 테스트(스프링 시큐리티) 만들었으니 테스트 해보기 위해 로그인 버튼을 만들어서 로그인 할 수 있도록 한다.

  • index.mustache
  1. {{#userName}}
    if문 처럼(if문 사실 없음) 저 userName이 있으면 userName이 노출되고 아래 코드 보여짐
  2. /logout
    저거 내가 만든 것도 아닌데 왜 있냐?
    스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL이다.(컨트롤러 만들 필요 없음)
    SecurityConfig 클래서에서 URL 변경 가능하지만 걍 기본 사용
  3. {{^userName}}
    userName이 없으면 ~
  4. /oauth2/authorization/google
    스프링 시큐리티에서 기본적으로 제공하는 로그인 URL
  • IndexController
    index.mustache에 userName이 넘어오려면 IndexController에서 model에 넣어서 넘겨줘야함
    CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하므로 가져와서 moel에 넣을 수 있다.

구글 로그인 버튼 잘 보이고

버튼 누르니 구글 로그인 뜬다.

아오 가리기 귀찮, 디비에 잘 뜨고

근데 자꾸 as user로 떠서 나중에 찾아봐야 할듯..

전에 로그인 할 때 GUEST 권한으로 하도록 만들었기 때문에 글을 작성할 수 없다.(403 : 권한 거부 뜰 것임)

권한을 변경하겠다.
일단 디비에서 일케 변경하면 글을 등록할 수 있게 된다.

다음엔 조금씩 기능을 개선하겠다..

2개의 댓글

comment-user-thumbnail
2023년 11월 7일

안녕하세요!! 글 정말 잘보고 있습니다. 깔끔하고 자세하게 설명해 주셔서 열심히 따라해보고 있는데, 질문이 있습니다. 혹시 이거는 세션을 이용해서 만드는 것일까요?

답글 달기
comment-user-thumbnail
2023년 11월 7일

{{#userName}} 이것도 mustache에서만 사용 가능한 것일까요..?

답글 달기