[SpringSecurity] 구글 로그인 API 연동하기

Kaite.Kang·2023년 1월 17일
0
post-thumbnail

* 목표

스프링 시큐리티와 OAuth를 이용하여 소셜 로그인 정보로 로그인 기능을 구현해보자.

1. 스프링 시큐리티와 OAuth2.0

1) 스프링 시큐리티

인증(누구인지)과 인가(어떤 것을 할 수 있는지) 기능을 가진 프레임워크이다.

사실상 스프링 기반의 애플리케이션에서는 보안을 위한 표준이라고 보면 된다.

2) OAuth2.0

  • (원리) SNS 서비스에서 발급하는 accessToken을 OAuth를 통해 획득한 후,
    accessTocken을 통해 SNS 서비스에 접근하여 필요한 데이터를 가져올 수 있다.

  • (장점) 회원들의 id와 password를 보관하지 않고, 회원들을 식별할 수 있다.

  • (특징) PHP, Node.js, Java, Python, Ruby 등 여러 개발 언어를 이용하여 구현되는 기술이다.

2. 구글 API 환경 설정

1) 구글 API 등록

먼저 구글 클라우드 플랫폼에서 프로젝트를 등록한다.

2) OAuth 설정값 등록

구글 클라우드 플랫폼에서 OAuth 등록에 필요한 데이터(클라이언트 ID와 클라이언트 보안 비밀 코드)를 받아와서 설정값으로 입력해 주어야 한다.

application-oauth.properties 에 OAuth 등록을 위한 설정값을 입력한다.

  • src/main/resources/application-oauth.properties
##GOOGLE
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

구글의 scope는 기본값이 openid, profile, email 이여서 별도로 등록해주지 않아도 된다.

하지만 여기서 scope 를 따로 등록해주는 이유는 구글 로그인과 네이버 로그인을 하나의 서비스로 구현해서 사용하기 위해서이다.

google의 scope 기본값인 openid가 있으면 Open Id Provider로 인식한다. 이렇게 되면 Open Id Provider인 서비스(구글)과 그렇지 않은 서비스(네이버, 카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 한다.

정리하자면 하나의 OAuth2Service로 사용하기 위해 일부러 구글의 scope에서 openid 를 빼고 등록한다.

(사용 사례) Open ID Provider vs OAuth
OpenID Connect:  Google을 사용하여 YouTube 또는 Facebook과 같은 애플리케이션에 로그인하여 온라인 장바구니에 로그인했다면 이 인증 옵션에 익숙할 것이다. OpenID Connect는 조직에서 사용자를 인증하는 데 사용하는 개방형 표준이다. IdP는 이를 사용하여 사용자가 IdP에 로그인한 다음 로그인하거나 로그인 정보를 공유하지 않고도 다른 웹사이트 및 앱에 액세스할 수 있다.
OAuth 2.0:  새 애플리케이션에 가입하고 Facebook 또는 휴대폰 연락처를 통해 새 연락처를 자동으로 제공하도록 동의했다면 OAuth 2.0을 사용했을 가능성이 크다. 이 표준은 보안 위임 액세스를 제공한다. 즉, 자격 증명을 공유하지 않고도 애플리케이션이 사용자 대신 서버에서 작업을 수행하거나 리소스에 액세스할 수 있다. ID 제공자(IdP)가 사용자의 승인을 받아 타사 애플리케이션에 토큰을 발행 할 수 있도록 허용함으로써 이를 수행한다.

3) 설정 파일 등록

스프링 부트에서는 properties의 이름을 application-xxx.properties 만들면 xxx라는 이름으로 profile이 생성되어 관리할 수 있다. 즉, profile=xxx 라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있다.

oauth라는 이름으로 호출하기 위해 기본 설정 파일인 application.properties에 등록해야 한다. 다음 코드는 application-oauth.properties 를 포함하도록 등록하는 구문이다.

  • application.properties
spring.profiles.include=oauth

4) “.gitignore” 등록

application-oauth.properties 파일이 git에 올라기는 것을 방지하기 위해 .gitignore 파일에 등록한다.

  • .gitignore
application-oauth.properties

3. 구글 로그인 연동하기

1) OAuth2 로그인 URL 설정하고 사용하기

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

서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 도메인(구글, 네이버 등)에서 리다이렉트할 URL이다.

로그인 서비스를 제공하는 도메인의 API 설정 페이지에서 사용할 URL을 추가해야 한다.

Google의 경우 ‘승인된 리디렉션 URL’칸에 다음 URL을 추가한다.

http://localhost:8081/login/oauth2/code/google

화면에서 호출할 컨트롤러는 스프링 시큐리트에서 로그인 URL을 제공하기 때문에 개발자가 별도의 컨트롤러를 생성할 필요가 없다.

화면 단에서 컨트롤러 URL을 호출할 때는 /oauth2/authorization/ 까지는 고정이고 마지막 Path만 각 소셜 로그인 코드를 사용하면 된다.(기본값으로는 /oauth2/authorization/ 을 사용하고 있지만 로그인 URL도 재정의가 가능하다.)

예를 들어 구글 로그인의 경우 화면단에서 컨트롤러를 호출할 때 다음과 같이 호출할 수 있다.

<a href="/oauth2/authorization/google">Google</a>

4. 로그인 서비스 로직 구현

1) User: 사용자 도메인

로그인 과정을 통해 얻은 사용자 데이터를 저장할 엔티티가 필요하다. 아래 User 클래스는 사용자 정보를 담당할 도메인이다.

  • src/main/java/com/spring/book/domain/user/User.java
package com.spring.book.domain.user;

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

    @Enumerated(EnumType.STRING)//(1)
    @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();
    }
}
  • (1) @Enumerated(EnumType.STRING)
    • JPA 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지 결정한다.
    • 기본값은 int형 이다.
    • int형으로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수 없기 때문에 문자열(EnumType.STRING)로 저장될 수 있도록 선언한다.

2) Role: 각 사용자의 권한을 관리할 Enum 클래스

사용자 권한을 설정하기 위한 Enum 클래스이다. Enum 은 상수를 의미한다.

  • src/main/java/com/spring/book/domain/user/Role.java
package com.spring.book.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_GUEST, ROLE_USER 등으로 저정하였다.

3) UserRepository: User 도메인의 리포지토리

UserRepository는 User 도메인의 CRUE를 담당할 리포지토리이다.

  • src/main/java/com/spring/book/domain/user/UserRepository.java
package com.spring.book.domain.user;

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을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드이다.

4) Spring Security 설정

SecurityConfig 클래스에서는 WebSecurityConfigurerAdapter를 상속하여 스프링 시큐리티 설정을 구현한다.

  • com/spring/book/config/auth/SecurityConfig.java
package com.spring.book.config.auth;

import com.spring.book.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
                //h2.console을 사용하기 위해 해당 옵션 disable
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()
                //URL 별 관리를 설정하는 옵션
                    .authorizeRequests()
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                //logout 기능에 대한 여러 설정의 진입점
                    .logout()
                        .logoutSuccessUrl("/")
                .and()
                //login 기능에 대한 여러 설정의 진입점
                    .oauth2Login()
                        .userInfoEndpoint()
                        .userService(customOAuth2UserService);
                        //로그인 성공시 후속 조치를 진행할 UserService 인터페이스 구현체를 등록
    }
}

이 클래스는 WebSecurityConfigurerAdapter 클래스를 상속하여 구현한다. 참고로 현재 springboot2.7 이상, spring security 5.7에서는 WebSecurityConfigurerAdapter 클래스가 Deprecated 되었다.(공식 홈페이지)

  • http.csrf().disable()

    • CSRF(Cross site Request forgery)는 웹사이트 취약점 공격 중 하나이다.

    • CSRF protection은 spring security에서 default로 설정된다.

    • csrf protection을 적용하였을 때, html에서 다음과 같은 csrf 토큰이 포함되어야 요청을 받아들이게 됨으로써, 위조 요청을 방지하게 된다.

      <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
    • REST API에서 CSRF protaction을 disable 해도 되는 이유?

      • spring security documentation에 non-browser clients(클라이언트가 웹브라우저가 아닌 경우, 예를 들어 android, ios, window 등) 만을 위한 서비스라면 csrf를 disable 하여도 좋다고 한다.
      • 그 이유는 rest api를 이용한 서버라면, session 기반 인증과는 다르게 stateless하기 때문에 서버에 인증 정보를 보관하지 않는다. rest api에서 client는 권한이 필요한 요청을 하기 위해서는 요청에 필요한 인증 정보를(OAuth2, jwt토큰 등)을 포함시켜야 한다. 따라서 서버에 인증 정보를 저장하지 않기 때문에 굳이 불필요한 csrf 코드들을 작성할 필요가 없다.
    • 참고: Spring security - csrf란?

  • http.headers().frameOptions().disable()

    • Spring Security는 사용자가 기본 보안 헤더를 쉽게 주입하여 애플리케이션을 보호하기 위한 목적으로 헤더에 기본 보안값을 가지고 있다. 그 중 하나가 X-Frame-Options: DENY 이다.

    • frame을 사용하지 않는 경우가 많은데 동일 도메인에서 iframe을 통해 접근할 경우 X-Frame-Options을 DENY로 설정하면 최신 브라우저에 접근할 수 없는 문제가 있기 때문에 이 설정을 disable 해준다.

      iframe(inline frame의 약자)이란?
      효과적으로 다른 HTML 페이지를 현재 페이지에 포함시키는 중첩된 브라우저로

      iframe 요소를 이용하면 해당 웹 페이지 안에 어떠한 제한 없이 다른 페이지를 불러와서 삽입 할 수 있다.

5) CustomOAuth2UserService: 사용자 정보 설정 로직

이 클래스에서는 구글 로그인 이후 가져온 사용자의 정보(email, name, picture 등)들을 기반으로 가입 및 정보 수정, 세션 저장 등의 기능을 지원한다.

  • com/spring/book/config/auth/CustomOAuth2UserService.java
package com.spring.book.config.auth;

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

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

        //OAuthAttributes 의 속성값을 가져옴.
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        //가져온 속성값을 user 객체로 변환
        User user = saveOrUpdate(attributes);
        //세션에 사용자 정보를 저장하기 위해 user 객체를 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);
    }

}

loadUser 메소드는 최종사용자의 속성을 매개변수로 받고, OAuth2User 타입의 객체를 return 한다. (OAuth2User 클래스는 하나 이상의 속성(예: 이름, 중간 이름, 성, 이메일, 전화번호, 주소 등)으로 구성된다.)

6) OAuthAttributes: DTO

  • src/main/java/com/spring/book/config/auth/dto/OAuthAttributes.java
package com.spring.book.config.auth.dto;

import com.spring.book.domain.user.Role;
import com.spring.book.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) {
        if("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }

        return ofGoogle(userNameAttributeName, attributes);
    }

    //OAuth2User에서 반환하는 사용자 정보는 Map이기 떄문에 값을 하나하나 변환해야 함.
    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 엔티티를 생성
    public User toEntity(){
         return User.builder()
                 .name(name)
                 .email(email)
                 .picture(picture)
                 .role(Role.GUEST)
                 .build();
    }
}
  • of()
    • 스프링 시큐리티에서 제공하는 OAuth2User에서 return하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환하기 위해 사용된다.
  • toEntity()
    • User 엔티티를 생성한다.
    • OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때이다.
    • 가입할 때의 기본 권한은 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST를 사용한다.
    • OAuthAttributes 클래스 생성이 끝났으면 같은 패키지에 SessionUser 클래스를 생성한다.

7) SessionUser: DTO

SessionUser에는 직렬화(Serializable)를 구현하였다.

인증된 사용자 정보만 필요하므로 name, email, picture 만 필드로 선언한다.

  • src/main/java/com/spring/book/config/auth/dto/SessionUser.java
package com.spring.book.config.auth.dto;

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

* User 클래스에 직렬화를 구현하지 않고, 별도의 클래스로 분리하였을까?

User 클래스가 엔티티이기 때문이다. 엔티티 클래스는 다른 엔티티와 관계가 형성될 수 있다. 자식 엔티티(ex. @OneToMany, @ManyToMany 등) 를 갖고 있다면 직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높다. 그래서 직렬화 기능을 가진 세션 DTO를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 도움이 된다.

8) 로그인 화면 수정

스프링 시큐리티가 잘 적용되었는지 확인하기 위해 화면에 로그인 버튼을 추가한다.

  • src/main/resources/templates/index.mustache

index.mustache는 userName이 있다면(로그인 되었다면) userName을 출력하고, 없다면 로그인 버튼을 보여준다.

...

<h1>스프링부트로 시작하는 웹 서비스 Ver.2</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 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>
    <br>
    <!-- 목록 출력 영역 -->

...
  • 머스테치 문법

    • 머스터치는 다른 언어들과 같은 if문을 제공하지 않지만 true/false 여부를 판단하는 문법은 있다.
    • {{#userName}}: 값이 존재하면 true이므로 실행한다.
    • {{^userName}}: 해당 값이 존재하지 않는 경우에는 ^을 사용한다.
  • URL

    • /logout 은 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL이다.
    • /oauth2/authorization/google 도 마찬가지로 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL이다.
  • src/main/java/com/spring/book/web/IndexController.java

index.mustache에서 userName을 사용할 수 있도록 IndexController에서 userName을 model에 저장하는 코드를 추가한다.

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService; //@RequiredArgsConstructor 생성자로 초기화함.
    private final HttpSession httpSession;

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

...

}

5. 테스트

  • 로그인 후 화면
  • h2 console에서 사용자를 select 하면 GUEST ROLE로 설정되어 있다.
  • GUEST는 /api/v1/** 경로에 접근 권한이 없기 때문에 게시글이 등록되지 않고 403 에러가 발생한다.
  • 사용자 권한을 User로 변경한다.

  • 재로그인 후에 /api/v1/posts 에 접근이 가능하다.

* 정리

  • SecurityConfig 클래스에서 스프링 시큐리티를 재정의 하였다.

  • 로그인 후 실행할 서비스 로직은 OAuth2UserService 인터페이스를 구현한 CustomOAuth2UserService 클래스에서 수행한다.

    • SecurityConfig 클래스에서 OAuth2UserService 구현체를 등록해 주어야 한다. http.oauth2Login().userInfoEndpoint().userService(서비스 클래스)
  • 컨트롤러, 서비스, 리포지토리 사이에서 데이터를 교환할 때는 도메인외에 별도의 DTO를 만들어 주는 것이 좋다.

  • 세션에 저장할 객체는 도메인외에 별도의 DTO로 생성하는 것이 좋다. 도메인은 자식 엔티티가 포함될 수 있어서 성능 이슈나 예상치 못한 부수 효과가 발생할 수 있기 때문이다.

  • 화면에서 로그인 버튼을 구현할 때는 (구글) API 설정 페이지에서 설정한 로그인 URL을 연결해준다.

Reference

참고

도서 - 스프링 부트와 AWS로 혼자 구현하는 웹 서비스

0개의 댓글