좌선
좌선의 개발일지
좌선
전체 방문자
오늘
어제
  • 분류 전체보기 (79)
    • 개발환경 (3)
    • 알고리즘 (10)
      • 코딩테스트 (9)
      • 알고리즘 (1)
    • Java (54)
      • JPA (6)
      • Spring (5)
      • SpringBoot (15)
    • Network (2)
    • Database (1)
    • Git (2)
    • & (1)
    • Exception (3)
    • Study (0)
      • RealMySQL (0)
    • 회고 (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • AWS
  • 스프링부트와 AWS로 혼자 구현하는 웹 서비스
  • SpringBoot
  • 스프링부트
  • 테스트주도개발
  • 머스테치
  • TDD 실천법과 도구
  • Mustache
  • 프로그래머스
  • 자바웹을다루는기술
  • 더티체킹
  • dirtychecking
  • 스프링 부트와 AWS로 혼자 구현하는 웹 서비스
  • 자바 웹을 다루는 기술
  • 다트게임
  • programmers
  • 코드로배우는스프링웹프로젝트
  • Spring
  • 쿼리로그
  • Spring Data JPA

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
좌선

좌선의 개발일지

[SpringBoot] 스프링부트 구글 로그인 연동하기
Java/SpringBoot

[SpringBoot] 스프링부트 구글 로그인 연동하기

2021. 7. 27. 18:54

스프링부트 실습 Chapter05 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기

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

 

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

Oauth

인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해

웹 사이트나 애플리케이션의 접근 권한을 할 수 있는 공통적인 수단

출처 | https://ko.wikipedia.org/wiki/OAuth

 

소셜 로그인을 사용하는 이유

로그인을 직접 구현 시 아래와 같은 사항들을 전부 구현해야한다.

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

OAuth 로그인 구현 시 앞선 목록의 것들을 모두 구글, 페이스북, 네이버에 맡기면 된다.

 

스프링 부트 2.0 방식

Spring Security Oauth2 Client 라이브러리 사용

 

🔎 구글 서비스 등록

구글 클라우드 플랫폼 접속

https://console.cloud.google.com/

 

프로젝트 선택 클릭

 

새 프로젝트 클릭

 

프로젝트 이름 적어주고 만들기 클릭

 

방금 만든 프로젝트 선택

 

메뉴 - API 및 서비스 - 사용자 인증 정보

 

사용자 인증 정보 만들기 클릭

 

OAuth 클라이언트 ID 클릭

 

동의 화면 구성 클릭

 

외부 클릭하고 만들기

 

앱 이름, 사용자 지원 이메일 작성

앱 이름 : 구글 로그인 시 사용자에게 노출될 애플리케이션 이름

지원 이메일 : 사용자 동의 화면에서 노출될 이메일 주소. 보통 서비스의 help 이메일 주소 사용

없으면 그냥 자기 이메일 넣으면 됨

 

하단의 개발자 연락처 정보 입력하고 저장 후 계속

 

범위 추가 또는 삭제 클릭

 

email, profile, openid 클릭 후 하단 업데이트 클릭

 

추가되었으면 저장 후 계속 버튼 클릭

 

테스트 사용자 추가 필요하면 하고 저장 후 계속

마지막 요약 확인 후 하단 대시보드로 이동 클릭

 

사용자 인증 정보 - 사용자 인증 정보 만들기 - OAuth 클라이언트 ID

 

애플리케이션 유형 - 웹 애플리케이션

 

승인된 리디렉션 입력

http://localhost:포트번호/login/oauth2/code/google

서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL

스프링 부트 2버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원

시큐리티에서 이미 구현해 놓았기 때문에 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요가 없다.

만약 AWS 서버에 배포하게 될 시 localhost 외에 추가로 주소를 추가해야 한다.

 

클라이언트 ID가 생성된 걸 확인할 수 있다.

 

👉 application-oauth 등록

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

 

src/main/resources 디렉토리에 application-oauth.properties 파일에 클라이언트 ID, 클라이언트 보안 비밀 입력

 

scope=profile, email

기본값이 openid, profile, email이기 때문에 많은 예제에서는 이 scope를 별도로 등록하지 않는다.

 

💡 강제로 profile, email을 등록한 이유

openid라는 scope가 있으면 Open Id Provide로 인식하기 때문

-> OpenId Provider인 서비스(구글)과 그렇지 않은 서비스(네이버/카카오 등)으로 나누어서 각각 Oauth2Service를 만들어야함.

하나의 OAuth2Service로 사용하기 위해 oepnid scope를 빼고 등록한다.

 

스프링 부트에서 properties의 이름을 application-xxx.properties로 만들면

xxx 라는 이름의 profile이 생성되어 관리할 수 있다.

profile=xxx 라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있음.

 

spring.profiles.include=oauth

application.properties에 코드 추가하기

oauth를 추가해주면 application-oauth.properties 파일의 설정값을 사용할 수 있다.

 

👉 .gitignore 등록

구글 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀은 보안이 중요한 정보들이다.

외부에 노출될 경우 취약점이 될 수 있기 때문에 .gitignore에 등록해준다.

 

application-oauth.properties

.gitignore에 해당 코드 추가하기

 

💡 추가한 뒤 커밋했을 때 application-oauth.properties 파일이 나온다면 캐시문제 때문이다.

git rm -r --cached .

위 코드로 캐시를 지운 후 다시 커밋하기

 

참고 | https://jojoldu.tistory.com/307

 

🔎구글 로그인 연동하기

👉 User 엔티티 관련 코드 작성

 

User 클래스 생성

- 사용자 정보를 담당할 도메인 클래스

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

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

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

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

 

@Enumerated(EnumType.STRING)

JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지 결정

기본적으로 int로 된 숫자가 저장된다.

숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수가 없다.

따라서 문자열 (EnumType.STRING)으로 저장될 수 있도록 선언

 

 

Role 클래스 생성

- 각 사용자의 권한을 관리

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_GUEST, ROLE_USER 등으로 지정한다.

 

UserRepository 클래스 생성

- User의 CRUD를 책임지는 클래스

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

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
}

 

findByEmail

소셜 로그인으로 반환되는 값 중 email을 통해

이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드

 

 

👉 스프링 시큐리티 설정

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

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

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.projectlombok:lombok')
    annotationProcessor('org.projectlombok:lombok')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('com.h2database:h2')
    compile('org.springframework.boot:spring-boot-starter-mustache')
    compile('org.springframework.boot:spring-boot-starter-oauth2-client')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}

spring-boot-starter-oauth2-client

소설 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성

spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.

 

 

OAuth 라이브러리를 이용한 소셜 로그인 설정 코드 작성

config.auth 패키지 생성

시큐리티 관련 클래스가 담기는 곳

 

 

SecurityConfig 클래스 생성

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

 

@EnableWebSecurity

Spring Security 설정들을 활성화시켜 준다.

 

csrf().disable().headers().frameOptions().disable()

h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.

 

authorizeRequests

URL별 권한 관리를 설정하는 옵션의 시작점

authorizeRequests가 선언되어야 antMatchers 옵션을 사용할 수 있다.

 

antMatchers

권한 관리 대상을 지정하는 옵션

URL, HTTP 메소드별로 관리가 가능

permitAll() - 전체 열람 권한

hasRole(Role.USER.name()) - USER 권한을 가진 사람만 가능

 

anyRequest

설정된 값들 이외 나머지 URL들을 나타낸다.

authenticated() - 인증된 사용자들(로그인한 사용자들)에게만 허용하게 한다.

 

logout().logoutSuccessUrl("/")

로그아웃 기능에 대한 여러 설정의 진입점

로그아웃 성공 시 / 주소로 이동한다.

 

oauth2Login

Oauth 2 로그인 기능에 대한 여러 설정의 진입점

 

userInfoEndpoint

Oauth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당

 

userService

소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록

리소스 서버 (소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시한다.

 

 

CustomOAuth2UserService 클래스 생성

- 구글 로그인 이후 가져온 사용자의 정보(email, name, picture 등)등을 기반으로

가입 및 정보수정, 세션 저장 등의 기능을 지원

 

import com.jojoldu.book.springboot.domain.user.User;
import com.jojoldu.book.springboot.domain.user.UserRepository;
import com.jojoldu.book.springboot.web.dto.OAuthAttributes;
import com.jojoldu.book.springboot.web.dto.SessionUser;
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);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().
                                        getUserInfoEndpoint().getUserNameAttributeName();

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

        User user = saveOrUpadte(attributes);

        httpSession.setAttribute("user", new SessionUser(user));

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey()
        );
    }

    private User saveOrUpadte(OAuthAttributes attributes) {
        User user = userRepository.findByEmail(attributes.getEmail())
                .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
                .orElse(attributes.toEntity());

        return userRepository.save(user);

    }
}

 

registrationId

현재 로그인 진행 중인 서비스를 구분하는 코드

네이버 로그인 연동 시에 네이버 로그인인지, 구글 로그인인지 구분하기 위해 사용

 

userNameAttributeName

OAuth2 로그인 진행 시 키가 되는 필드값

Primary Key와 같은 의미

구글의 경우 기본적으로 "sub"이라는 코드를 지원

네이버, 카카오 들은 지원하지 않음

이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용

 

OAuthAttributes

OAuth2UserService를 통해 가져온 OAuth2User의 attributes를 담을 클래스

네이버 등 다른 소셜 로그인도 이 클래스를 사용

아래 OAuthAttributes 클래스 참조

 

SessionUser

세션에 사용자 정보를 저장하기 위한 Dto 클래스

 

 

OAuthAttributes 클래스 생성

- OAuth2UserService를 통해 가져온 OAuth2User의 attributes를 담을 클래스

 

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

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

    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }

}

 

of( )

OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야 한다.

 

toEntity( )

User 엔티티를 생성

OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때

가입할 때의 기본 권한을 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST 사용

OAuthAttributes 클래스 생성이 끝났으면 같은 패키지에 SessionUser 클래스 생성

 

 

SessionUser 클래스 생성

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

@Getter
public class SessionUser {
    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();
    }
}

 

SessionUser에는 인증된 사용자 정보만 필요하다.

그 외에 필요한 정보는 없기 때문에 name, email, picture만 필드로 선언

 

💡 왜 User 클래스 대신에 SessionUser 클래스를 사용하나?

- User 클래스를 그대로 사용했을 시 나타날 에러

Failed to convert from type [java.lang.Object] to type [byte[]] for value 'com.jojoldu.book.springboot.domain.user.User@4a43d6'

= User 클래스를 세션에 저장하려고 하니, User 클래스에 직렬화를 구현하지 않았다는 의미

User 클래스에 직렬화 코드를 넣으면?

User 클래스가 엔티티이기 때문에 엔티티 클래스에는 언제 다른 엔티티와 관계가 형성될지 모른다.

만약 자식 엔티티를 갖고 있다면 직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높음

따라서 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 운영 및 유지보수에 도움이 된다.

 

 

로그인 테스트

index.mustache에 로그인 버튼, 로그인 성공 시 사용자의 이름을 보여주는 코드 추가

 

 <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 id 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>
        <br>

 

{{#userName}}

머스테치는 다른 언어와 같은 if문(if userName != null)을 제공하지 않는다.

true/false 여부만 판단한다.

따라서 머스테치에서는 항상 최종값을 넘겨줘야 한다.

userName이 있다면 userName 노출

 

a href="/logout"

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

개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없다.

SecurityConfig 클래스에서 URL을 변경이 가능하긴 하다.

 

{{^userName}}

머스테치에서 해당 값이 존재하지 않는 경우에는 ^을 사용한다.

여기서는 userName이 없다면 로그인 버튼을 노출시키도록 구성

 

a href="/oauth2/authorization/google"

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

로그아웃 URL과 마찬가지로 개발자가 별도의 컨트롤러를 생설할 필요가 없다.

 

 

IndexController에서 userName을 model에 저장하는 코드 작성

 

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

 

(SessionUser) httpSession.getAttribute("user")

CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성함

즉, 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있다.

 

if(user != null)

세션에 저장된 값이 있을때만 model에 userName으로 등록

세션에 저장된 값이 없으면 model에 아무런 값이 없는 상태이니 로그인 버튼이 보이게 된다.

 

 

✨ 해당 실습 코드는 GitHub에 업로드 합니다.

    'Java/SpringBoot' 카테고리의 다른 글
    • [SpringBoot] AWS 데이터베이스 환경 구축 - AWS RDS (MariaDB)
    • [SpringBoot] AWS 서버 환경 - AWS EC2 설정하기
    • [SpringBoot] 템플릿 엔진 머스테치로 화면 구성하기
    • [SpringBoot] 스프링부트 JPA로 데이터베이스 다루기
    좌선
    좌선
    얼렁뚱땅 천방지축 굴러가는 개발자의 삶

    티스토리툴바