관리 메뉴

제뉴어리의 모든것

Spring boot에 Google Login 연동하기 본문

기타...

Spring boot에 Google Login 연동하기

제뉴어리맨 2021. 2. 18. 18:50

* 인텔리제이 아이디어(IntelliJ IDEA) (커뮤니티 가능) , JPA 및 롬복(Lombok) 사용을 전제로 합니다.

 

해당 게시물은 스프링 부트(Spring Boot): 구글 로그인 연동 (스프링 부트 스타터의 oauth2-client) 이용 + 네이버 아이디로 로그인 - BGSMM (yoonbumtae.com) 를 참고하여 본인이 직접 개발한 내용을 토대로 작성되었습니다

 

  • 스프링 부트 버전 : 2.4.2
  • 메이븐 버전 : 2.4.2
  • 인텔리제이 버전 : Intellij Ultimate 2020.3.1
  • 마리아 db 버전 : mariadb-10.5.8-winx64
  • 자바 버전 : Amazon Corretto jdk11.0.10_9

이 방법은 JSTL, Thymeleaf, Mustache 등 서버 사이드 템플릿 엔진을 사용하는 로그인 방법입니다.

 

> 순서

  1. pom.xml에 디펜던시 추가
  2. application-oauth.properties 작성 + .gitignore 등록
  3. Role enum 클래스 작성 – 사용자 권한 관리
  4. User 클래스 작성 – JPA Entity 클래스
  5. OAuthAttributes 클래스 작성 – 구글 로그인 이후 가져온 사용자의 이메일, 이름, 프로필 사진 주소 를 저장하는 DTO
  6. CustomOAuth2UserService 클래스 작성 – OAuthAttributes을 기반으로 가입 및 정보수정, 세션 저장 등 기능 수행
  7. SecurityConfig 클래스 작성 – 스프링 시큐리티 설정
  8. SessionUser 클래스 작성 – User 엔티티 클래스에서 직렬화가 필요한 경우 별도로 사용
  9. UserRepository 클래스 생성
  10. IndexController 작성, Thymeleaf 뷰 페이지 작성

 

 

> 프로젝트 구조

 

> 사전 작업 >구글로부터 연동할 프로젝트(클라이언트)의 아이디와 비밀번호 받아오기

참조 : 구글 클라우드 플랫폼에 앱 클라이언트 생성하기 (tistory.com)

 

1. pom.xml에 dependency 추가

    <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>

 

2) application-oauth.properties 작성 + .gitignore 등록

위치는 application.properties가 있는 위치와 동일한 곳에 작성합니다. application-oauth.properties 작성 후, application.properties 파일에 등록해야 합니다.

그리고 비밀번호가 있으므로 Git을 사용한다면 이것이 커밋되지 않도록 .gitignore 파일에 추가해야 합니다.

 

  • application-oauth.properties
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

 

 

  • application.properties
#application-oauth.properties 로딩
spring.profiles.include=oauth

 

3) Role enum 클래스 작성

사용자의 권한을 enum 클래스로 만들어 관리합니다.

package com.portfolio.mymall.domain;

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

 

4) User 클래스 작성

엔티티(@Entity) 클래스는 JPA를 통해 SQL을 사용하지 않고도 자바 코드 내에서 테이블을 생성할 수 있습니다.

(application.properties에 spring.jpa.hibernate.ddl-auto=update 가 되 있어야한다.)

package com.portfolio.mymall.domain;

import com.portfolio.mymall.domain.Base_Entity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;


@Getter
@NoArgsConstructor
@Entity
public class User extends Base_Entity {

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

 

참고: BaseTimeEntity 클래스 (JpaAuditing – 글 작성 시점, 수정 시점 자동 추가)

User 클래스가 상속 받는 Base_Entity의 소스

package com.portfolio.mymall.domain;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@MappedSuperclass //클래스가 만들어지지 않는 기초 클래스라는 어노테이션
@Getter
@EntityListeners(value = {AuditingEntityListener.class}) //Entity의 변화를 감지하는 리스너
abstract class Base_Entity {

    @CreatedDate //만들어질때 입력 되는 필드임을 정의하는 어노테이션
    @Column(name = "reg_date", updatable = false)
    private LocalDateTime regDate;

    @LastModifiedDate //마지막 수정때 입력 되는 필드임을 정의하는 어노테이션
    @Column(name = "mod_date")
    private LocalDateTime modDate;

}

메인 애플리케이션에서 @EnableJpaAuditing 어노테이션을 추가해 활성화합니다.

package com.portfolio.mymall;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing //Base_Entity에 있는 날짜 자동 입력 활성화
@SpringBootApplication
public class MymallApplication {

    public static void main(String[] args) {
        SpringApplication.run(MymallApplication.class, args);
    }

}

 

5) OAuthAttributes 클래스 작성

구글 로그인 이후 가져온 사용자의 이메일, 이름, 프로필 사진 주소를 저장하는 DTO

package com.portfolio.mymall.dto;

import com.portfolio.mymall.domain.Role;
import com.portfolio.mymall.domain.User;
import lombok.Builder;
import lombok.Getter;

import java.util.Map;


@Getter
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey, name, email, 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) {
        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();
    }
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

 

6) CustomOAuth2UserService 클래스 작성

OAuthAttributes을 기반으로 가입 및 정보수정, 세션 저장 등 기능 수행합니다.

package com.portfolio.mymall.service;

import com.portfolio.mymall.config.auth.SessionUser;
import com.portfolio.mymall.domain.User;
import com.portfolio.mymall.dto.OAuthAttributes;
import com.portfolio.mymall.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 org.springframework.transaction.annotation.Transactional;

import javax.servlet.http.HttpSession;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

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

        //테스트
        System.out.println("==================== "+registrationId +" ==================");


        // oauth2 로그인 진행 시 키가 되는 필드값
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();


        //테스트
        System.out.println("================ "+userNameAttributeName+" =================");


        // OAuthAttributes: attribute를 담을 클래스 (개발자가 생성)
        OAuthAttributes attributes = OAuthAttributes
                .of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
        User user = saveOrUpdate(attributes);
        // SessioUser: 세션에 사용자 정보를 저장하기 위한 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) {
//        List<User> user = userRepository.findByEmail(attributes.getEmail())
//                .map(entity -> entity.update(attributes.getName(), attributes.getPicture())).collect(Collectors.toList());

        //        return userRepository.save(user.get(0));




        List<User> result = userRepository.findByEmail(attributes.getEmail())
                .map(entity -> entity.update(attributes.getName(), attributes.getPicture())).collect(Collectors.toList());

        User user;

        if(result.isEmpty())
        {
            user = attributes.toEntity();
        }
        else
        {
            user = result.get(0);
        }

        return userRepository.save(user);


    }
}

 

7) SecurityConfig 클래스 작성 – 스프링 시큐리티 설정

 

package com.portfolio.mymall.config;

import com.portfolio.mymall.domain.Role;
import com.portfolio.mymall.service.CustomOAuth2UserService;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final Logger logger = LoggerFactory.getLogger(SecurityConfig.class);
    

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable() 
                .headers().frameOptions().disable()
                .and()
                .authorizeRequests()
                .antMatchers("/", "/Savory-gh-pages/**").permitAll()
                //.anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/")
                .and()
                .logout().logoutSuccessUrl("/")
                .and()
                .oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);
        
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        logger.info("build Auth global.......");

        auth.inMemoryAuthentication()
                .withUser("user")
                .password("{noop}1111")
                .roles(Role.USER.name());
    }
}

> configure 함수 내용

csrf().disable() : basic auth를 사용하기 위해 csrf 보호 기능 disable

.authorizeRequests() : 요청에 대한 권한을 지정할 수 있다. (설정 시작점)

.antMatchers("/", "/Savory-gh-pages/**").permitAll() : "/", "/Savory-gh-pages/**" 으로 들어오는 요청은 모두 허용해준다 ("/" 은 index페이지를 호출하는 url, "/Savory-gh-pages/**" 은 /resource/static 에 있는 css 파일들이 정의 되어있는 디렉토리이므로 무조건 허용)

.anyRequest().authenticated() : 본 소스에서는 주석처리 됬지만, 어떠한 요청이든 인증 받고 가능하게 하는 설정.

본 사이트의 어떤 기능을 접근하려 해도 일단 로그인을 요청함.

.formLogin() : form 태그 기반의 로그인을 지원하겠다는 설정입니다. (개발자가 직접 login 폼을 만들 필요 없음)

.loginPage("/login") : "/login" 경로의 로그인 페이지를 연결

defaultSuccessUrl("/") : 로그인 성공 후 리다이렉트할 주소

.logout() : logout에 대한 설정

logoutSccessUrl() : 로그아웃 성공 후 리다이렉트할 주소

invalidateHttpSession() : 로그아웃 이후 세션 전체 삭제 여부

 

8) SessionUser 클래스 작성

User 엔티티 클래스에서 직렬화가 필요한 경우 별도로 사용하기 위한 클래스를 작성합니다.

package com.portfolio.mymall.config.auth;

import com.portfolio.mymall.domain.User;
import lombok.Getter;

import java.io.Serializable;

@Getter
public class SessionUser implements Serializable {
    private String name, email, picture;
    public SessionUser(User user) {
        this.name = user.getName();
        this.email = user.getEmail();
        this.picture = user.getPicture();
    }
}

 

9. UserRepository 생성

User 엔티티의 Select와 Save를 담당할 Repository를 임의로 생성한다

package com.portfolio.mymall.repository;
import com.portfolio.mymall.domain.User;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.stream.Stream;

public class UserRepository {

    private final EntityManager entityManager;
    public UserRepository(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
    
    public Stream<User> findByEmail(String email)
    {
        List<User> result = entityManager.createQuery("select U from User U where U.email = :email", User.class)
                .setParameter("email", email)
                .getResultList();
        if(result.isEmpty())
        {
            //예외처리
        }
        return result.stream();
    }

    public User save(User user) {
        entityManager.persist(user);
        return user;
    }
}

 

 

 

10) IndexController 작성, Thymeleaf 뷰 페이지 작성

  • View 페이지 (index.html)
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">

<th:block th:replace="~{layout/basic :: setContent(~{this::content})}"> <!-- fragment로 만들어서 본 페이지로 보냄 -->

    <th:block th:fragment="content">
        <h1>Welcome Page</h1>


        <div class="row">
            <div class="col-md-10">
                <div th:if="${not #strings.isEmpty(userName)}">
                    <img style="width:45px; height:45px" src="/image/unnamed.png" th:src="${userImg}"
                         class="rounded-circle img-thumbnail img-responsive">
                    <span id="login-user" th:text="${userName}">사용자</span> 님, 안녕하세요.
                    <a href="/logout" class="btn btn-sm btn-info active" role="button">Logout</a>
                </div>
                <div th:if="${#strings.isEmpty(userName)}">
                    <!-- 스프링 시큐리티에서 기본 제공하는 URL - 별도 컨트롤러 작성 필요 없음 -->
                    <a href="/oauth2/authorization/google" class="btn btn-sm btn-success active" role="button">Google Login</a>
                </div>

            </div>
<!--            <div th:if="${not #strings.isEmpty(userName)}" class="col-md-2">-->
<!--                <a href="/posts/save" role="button" class="btn btn-primary float-right">글 등록</a>-->
<!--            </div>-->
        </div>

    </th:block>

</th:block>
  • Index 컨트롤러
package com.portfolio.mymall.controller;

import com.portfolio.mymall.config.auth.SessionUser;
import com.portfolio.mymall.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.web.servlet.oauth2.login.OAuth2LoginSecurityMarker;
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 HomeController {

    Logger logger = LoggerFactory.getLogger(HomeController.class);

//    private final PostsService postsService;
    private final HttpSession httpSession;


    @GetMapping("/")
    public String index(Model model){

        SessionUser user = (SessionUser) httpSession.getAttribute("user");

        logger.debug("index");

        if(user != null) {
            model.addAttribute("userName", user.getName());
            model.addAttribute("userImg", user.getPicture());
        }

        return "index";
    }

}

 

결과

Google Login 버튼이 생성되고 클릭하면 아래와 같이 로그인 창이 생성 된다. (구글 클라우드 플랫폼 대시보드에 등록했던 계정으로 로그인할것)

로그인 을 하면 아래와 같이 로그인이 정상적으로 완료된다.

 

그리고 로그인을 하면 서버의 DB에 있는 User 테이블에 해당 유저의 정보를 Google 로부터 받아서 저장한다.

그리고 기존에 본 사이트에 Google로 로그인 한 적이 있는 유저고 이름등과 같은 정보가 변경 되었다면 update 된다.

 

 

 

 

 

 

참조 : 15. 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 (2) (tistory.com)

 

15. 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 (2)

domain 패키지 하위에 사용자 정보를 담당할 User 도메인을 만들도록 하겠습니다. User.java package com.springbootwithaws.book.springboot.domain.user; import com.springbootwithaws.book.springboot.domain...

for1123.tistory.com

[Spring] Spring Security - 인증, 인가, CSRF, Test (tistory.com)

 

[Spring] Spring Security - 인증, 인가, CSRF, Test

[Spring] Spring Security Spring Security - 어플리케이션에 보안 기능을 구현할 때 사용되는 프레임워크 - servlet container에 배포하는 어플리케이션에 활용 기능 1. 인증/인가 - 인증 : 사용자의 정당성 확인..

dongdd.tistory.com

Spring Security로 로그인/회원가입 프로젝트 (tistory.com)