본문 바로가기
springboot

[springboot] 구글 로그인 api 연동

by 개발LOG 2024. 2. 18.

먼저, 구글 서비스에 신규 서비스를 행성해야 한다. 여기서 발급된 인증 정보(clientId와 clientSecret)를 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있으니 무조건 발급받고 시작해야 한다.

구글 서비스 등록

https://console.cloud.google.com 에 접속해 새 프로젝트 를 클릭한다.

 

프로젝트이름을 기입하고 만들기 버튼을 클릭한다.

 

그 다음 왼쪽바에서 API 및 서비스-> 사용자 인증 정보 를 클릭한다.

사용자 인증 정보 만들기-> OAuth 클라이언트 ID 를 클릭한다.

동의 화면 구성 버튼을 클릭한다.

 

외부로 선택하고 만들기 버튼 클릭한다. (나중에 배포를 생각해서 외부로 택하기)

앱이름과 이메일 선택하고 마지막 개발자 연락처 정보에 이메일 주소 적고 저장후계속 클릭한다.

 

범위에서 범위 추가 또는 삭제를 클릭한 뒤 email,profile,openid 전체 선택 후 업데이트 버튼 후 저장후계속을 클릭한다.

테스트사용자도 무시하고 저장후계속을 클릭한다.

대시보드로 돌아가기 버튼을 클릭한다.

 

왼쪽바에서 사용자 인증 정보 클릭 후 사용자 인증 정보 만들기->OAuth 클라이언트 ID 만들기 버튼을 클릭한다.

애플리케이션 유형에서 웹 애플리케이션을 클릭하고 이름에 프로젝트명을 기입한다.

아래로 내려서 승인된 리다렉션 URI에 프로젝트 포트번호에 맞춰  http://localhost:9091/login/oauth2/code/google 라고 적어준다.
 http://localhost:9091/login/oauth2/code/google

 

발급받은 클라이언트ID클라이언트 보안 비밀번호를 프로젝트에 설정해야 한다.

application-oauth.properties파일을 src/main/resources/ 디렉토리에 생성한 후 발급받은 ID, 비밀번호를 써준다.

# google
spring.security.oauth2.client.registration.google.client-id=
spring.security.oauth2.client.registration.google.client-secret=
spring.security.oauth2.client.registration.google.scope=profile,email

( 많은 예제에서는 scope를 별도로 등록하지 않고 있다. 기본값이 openid, profile, email이기 때문이다. 강제로 profile, email을 등록한 이유는 openid라는 scope가 있으면 Open Id Provider로 인식하기 때문이다. 이렇게 되면 Open Id Provider인 구글과 그렇지 않은 서비스(카카오/네이버 등)로 나눠서 각각 OAuth2Service를 만들어야 한다. 하나의 OAuth2Service로 하기 위해 일부러 openid scope를 빼고 등록한다. )

 

application.properties 파일에 아래 내용을 추가해 준다.
# oauth 설정값 사용 (application-oauth.properties에서 Id,비밀번호,scope 설정함)
spring.profiles.include=oauth
보안에 관련된 내용이므로 .gitignore 파일에 아래 내용 추가하기
# 스프링 oauth 보안 설정 제외
application-oauth.properties

 

구글 로그인 연동하기

domain/user 패키지 만들어서 안에 User클래스 생성하기

package com.pnow.domain.user;

import com.pnow.domain.BaseTimeEntity;
import com.pnow.domain.Bookmark;
import com.pnow.domain.Reservation;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.List;

@NoArgsConstructor //기본생성자
@Table(name = "users") //user는 예약어라서 쓰면 안되니까 users라고 테이블명 셋팅
@Entity
@Getter
public class User extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    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; //ROLE_USER

    @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
    private List<Bookmark> bookmarkList;

    @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
    private List<Reservation> reservationList;

//    @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
//    private List<Review> reviewList;

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

}
domain/user 패키지안에 Role enum 생성
package com.pnow.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_ 이 앞에 있어야만 한다. 그래서 코드별 키 값을 ROLE_USER 등으로 지정한다.

repository패키지를 만들어 UserRepository 인터페이스를 만든다.
package com.pnow.repository;

import com.pnow.domain.user.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email); //email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드
}

 

스프링 시큐리티 설정

buile.gradle에 아래 의존성 추가
// spring security oauth2 client
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
config.auth 패키지 생성(시큐리티 관련 설정은 여기서 한다.)한 후 SecurityConfig 파일 생성한다.
package com.pnow.config.auth;

import com.pnow.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()
                .authorizeHttpRequests()
                .antMatchers("/", "/css/**", "/img/**", "/js/**","/scss/**","/vendor/**", "/h2-console/**","/store/**", "/profile").permitAll() //이쪽 url들은 아무런 권한없이 들어갈 수 있다.
                .antMatchers("/bookmark/**", "/reservaion/**").hasRole(Role.USER.name()) //지정된 옵션에는 전체 열람 권한 부여 => 권한이 ROLE_USER인 경우
                .anyRequest().authenticated() //로그인 한 사용자들에게 허용
                .and()
                .logout().logoutSuccessUrl("/") //로그아웃 성공시 "/"주소로 이동
                .and()
                .oauth2Login() //OAuth2 로그인 기능에 대한 여러 설정의 진입점
                .userInfoEndpoint() //OAuth2 로그인 성공 이후 사용자 정보 가져올 때의 설정 담당
                .userService(customOAuth2UserService); //소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체 등록
    }

}

 

config/auth 패키지 안에 customOAuth2UserService 파일 생성

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

package com.pnow.config.auth;


import com.pnow.config.auth.dto.OAuthAttributes;
import com.pnow.config.auth.dto.SessionUser;
import com.pnow.domain.user.User;
import com.pnow.repository.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); //인터페이스에 userRequest를 받아 로드

        //1. 현재 로그인 진행 중인 서비스(구글, 로그인)를 구분
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        //2. OAuth2 로그인 진행 시 키가 되는 필드 = Primary Key
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        //-------- OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        User user = saveOrUpdate(attributes); //소셜로그인 인증한 OAuthAttributes dto를 User 엔티티에 저장

        httpSession.setAttribute("user", new SessionUser(user)); // SessionUser dto에 User 엔티티를 담아서 세션에 "user"로 저장
        //User 엔티티를 세션에 저장하면 직렬화 구현하지 않았다는 에러 발생, 직렬화기능을 가진 세션 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);

    }

}
config/auth/dto패키지에 OAuthAttributes 클래스 생성
package com.pnow.config.auth.dto;

import com.pnow.domain.user.Role;
import com.pnow.domain.user.User;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
/*
* 구글 소셜로그인 인증 후 OAuthAttributes dto에 셋팅해서 CustomOAuth2UserService에서 dto를 넘겨줄 것임
* */
@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);
    }

    //구글 인증
    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();
    }


    //User 엔티티 생성, OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때임
    //가입할 때의 기본 권한을 USER로 주기 위해서 role 빌더값에 Role.USER를 사용
    //OAuthAttributes 클래스 생성이 끝났으면 같은 패키지에 SesstionUser 클래스를 생성함
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.USER)
                .build();
    }
}
config/auth/dto 패키지에 SessionUser 클래스 추가

User 엔티티 클래스 대신 직렬화 기능을 가진 SessionUser dto 를 추가해 성능이슈, 부수효과 등의 문제발생을 막는다.

package com.pnow.config.auth.dto;

import com.pnow.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();
    }
}

 

세션에 로그인유저 저장한 것 매번 부르는 중복코드를 없애기 위해 config/auth 패키지에 LoginUser 인터페이스 생성

LoginUser라는 어노테이션 생성

package com.pnow.config.auth;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/*
* LoginUser라는 어노테이션 생성
* */
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

 

같은 위치에 LoginUserArgumentResolver 클래스 생성

 

package com.pnow.config.auth;

import com.pnow.config.auth.dto.SessionUser;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import javax.servlet.http.HttpSession;
/*
* HandlerMethodArgumentResolver 인터페이스를 구현한 클래스
* (조건에 맞는 경우 구현체가 지정한 값으로 해당 메소드의 파라미터로 넘길 수 있음)
* */
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    @Override
    public boolean supportsParameter(MethodParameter parameter) { //컨트롤러 메소드의 특정 파라미터를 지원하는지 판단
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null; //@LoginUser 어노테이션이 붙어 있는지
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType()); //파라미터 클래스 타입이 SessionUser.class인 경우 true 반환

        return isLoginUserAnnotation && isUserClass;
    }

    @Override // 파라미터에 전달할 객체 생성 -> 세션에서 객체 가져 옴
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}

 

 

LoginUserArgumentResolver를 스프링에서 인식될 수 있도록 config 패키지에 WebConfig 클래스 생성
package com.pnow.config;

import com.pnow.config.auth.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/*
* LoginUserArgumentResolver를 스프링에서 인식할 수 있도록하는 클래스
* */
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

 

컨트롤러에 @LoginUser 이용해서 세션 정보 저장
package com.pnow.controller;

import com.pnow.config.auth.LoginUser;
import com.pnow.config.auth.dto.SessionUser;
import com.pnow.domain.CategoryType;
import com.pnow.service.StoreService;
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;

@RequiredArgsConstructor
@Controller
public class MainController {

    private final StoreService storeService;
    private final HttpSession httpSession;

    @GetMapping("/")
    public String root(Model model, @LoginUser SessionUser user) {
        model.addAttribute("CategoryType", CategoryType.values());
        
        if( user != null){
            model.addAttribute("userName",user.getName());
        }
        return "home";
    }

}

 

home.html에 로그인 로그아웃 버튼 추가
<!-- 로그인 기능 영역 -->
			<div th:unless="${userName}">
                <a th:href="@{/oauth2/authorization/google}" class="btn btn-success active" role="button">Google Login</a>
            </div>
<!-- 로그아웃 기능 영역 -->            
            <div th:if="${userName}">
                Logged in as : <span id="user" th:text="${userName}"></span>
                <a th:href="@{/logout}" class="btn btn-info active" role="button">Logout</a>
            </div>

로그인 안했을 때

 

로그인 했을 때

최종적 패키지 구조:

 

 

h2 데이터베이스를 세션저장소로 셋팅

세션 저장소는 총 3가지 방법이 있다.

(1) 톰캣 세션을 사용

  • 일반적으로 별다른 설정을 하지 않을 때 기본적으로 선택되는 방식
  • 이렇게 된 경우 톰캣(WAS)에 세션이 저장되기 때문에 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요하다.

(2) MySQL과 같은 데이터베이스를 세션 저장소로 사용

  • 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법
  • 많은 설정이 필요 없지만, 결국 로그인 요청마다 DB IO가 발생하여 성능상 이슈가 발생할 수 있다.
  • 보통 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용

(3) Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용

  • B2C 서비스에서 가장 많이 사용하는 방식
  • 실제 서비스로 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요하다.

아래는 2번째 세션 저장소를 쓰고 있는 데이터베이스와 연결 짓고 싶을 때 과정이다. 

build.gradle에 spring-session-jdbc 추가
//spring-session-jdbc 등록(데이터베이스를 세션 저장소로 사용하기)
	implementation 'org.springframework.session:spring-session-jdbc'

 

application.properties 파일에 설정 추가
# 세션저장소를 jdbc로 선택하도록 설정
spring.session.store-type=jdbc
spring.session.jdbc.initialize-schema=always

 

h2-console에 들어가 보면 SPRING_SESSTION 테이블 생긴 것 확인 가능

구글 로그인 한 후 확인해보면 세션 저장된 것 확인 가능