[Spring Boot] 스프링 시큐리티, 구글 OAuth 2.0 로그인 (1)

2020. 9. 17. 14:49Spring/Spring Boot

1. 구글 클라우드 플랫폼

1) 스프링 시큐리티

스프링 시큐리티는 인증과 인가 기능을 가진 프레임워크이다. 스프링 시큐리티는 스프링 기반의 보안을 위한 표준으로, 인터셉터와 필터 기반의 보안 기능보다 더 권장한다.

2) 소셜 로그인

대부분 로그인 시 자체 회원 아이디와 비밀번호보다는 소셜 로그인(네이버, 구글, 카카오)를 더 많이 사용한다. 그 이유는, 소셜 로그인에 비해, 자체 회원을 위한 로그인에는 많은 기능(로그인 보안, 비밀번호 찾기/변경, 회원 정보 변경, 이메일/휴대폰 인증)이 요구되기 때문이다. 

3) 스프링 부트 2.0

스프링 부트 1.5의 OAuth2 연동 방법이 2.0에서는 크게 변경되었다. 그럼에도 불구하고 크게 이슈화가 되지 않은 이유는 'spring-security-oauth2-autoconfigure' 라이브러리를 통해 1.5에서 사용하던 설정을 그대로 적용할 수 있기때문이다.

 

하지만 OAuth2를 권장하는 이유는, 1.5에서 사용된 'spring-security-oauth' 프로젝트는 유지 상태로, 신규 기능 추가 없이 버그 수정 정도로만 업데이트될 예정이고 신규 기능은 OAuth2 라이브러리에만 지원하겠다고 선언했기 때문이다. 그 외에도  OAuth2에 스프링 부트용 스타터 라이브러리 출시 및 1.5의 확장 포인트를 고려한 설계가 되었기에 사용을 권장한다.

 

스프링 부트 2 방식의 자료를 검색할 때에는 'spring-security-oauth2-autoconfigure' 라이브러리 사용 유무와 2 방식의 'application.properties/application.yml' 정보만 확인하여 찾으면 된다. 1.5 방식에서 url 주소를 명시하는 방법과 달리, 2 방식에서는 클라이언트 인증 정보만을 입력하면 된다. 

spring:
    security:
    oauth2:
    client:
        clientId: 인증정보
        clientSecret: 인증정보

1.5에서 직접 입력했던 값들은 2 버전에서 CommonOAuth2Provider라는 enum이 추가되어 대체되었다.

public enum CommonOAuth2Provider {
	
    GOOGLE {
    	
        @Override
        public Builder getBuilder(String registrationId) {
        	
            ClientRegistration.Builder builder = getBuilder(
                registrationId, 
                ClientAuthenticationMethod.BASIC,
                DEFAULT_REDIRECT_URL
            );
            
            builder.scope("openid", "profile", "email");
            builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
            builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
            builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
            builder.userInfoUri("https://www.googleapis.com./oauth2/v3/userinfo");
            builder.userNameAttributeName(IdTokenClaimNames.SUB);
            builder.clientName("Google");
            
            return builder;
        }
    },
    ...
}

4) 구글 서비스 프로젝트 생성

구글 서비스에 신규 서비스를 생성한다. 여기서 발급된 인증 정보(clientId, clientSecret)를 통해 로그인 기능과 소셜 서비스 기능을 사용할 수 있기에 반드시 발급받고 시작해야한다. 구글 클라우드 플랫폼(console.cloud.google.com/)으로 이동하여 신규 프로젝트를 생성한다.

프로젝트 생성

5) 구글 서비스 동의 화면 구성

OAuth 클라이언트 ID를 구성하기 위해서 먼저 동의 화면을 구성한다.

항목 설명
내부 / 외부 내부로 설정할 경우 GSuit 사용자가 앱을 사용할 수 있으며, 외부로 설정할 경우 구글 계정이 있는 모든 사용자가 앱을 사용할 수 있다.
애플리케이션 이름 구글 로그인 시 사용자에게 노출될 애플리케이션 이름을 뜻한다.
지원 이메일 사용자 동의 화면에서 노출될 이메일 주소이다. 보통은 서비스 help 이메일 주소를 사용한다.
Google API 범위 구글 서비스에서 사용할 범위 목록으로, 기본값은 email, profile, openid이다.

6) 구글 서비스 클라이언트 ID 생성

항목 설명
애플리케이션 유형 현재 진행하고 있는 서비스의 유형을 선택한다.
승인된 리디렉션 URL 서비스에서 파리미터로 인증 정보를 줄 때 인증이 성공하면 구글에서 리다이렉트할 URL이다. 

※ 승인된 리디렉션 URL
- 스프링 부트 2의 시큐리티에서는 기본적으로 '도메인/login/oauth2/code/{소셜서비스코드}'로 리다이렉트로 지원하고 있다.
- 시큐리티에서 이미 구현해 놓았기 때문에 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요가 없다.
- AWS 서버에 배포하게 될 경우에는 서비스 도메인을 별도로 추가해주어야 한다.

클라이언트 ID 생성

 

 

 

2. 프로젝트 설정

1) application-oauth.properties

'src/main/resources' 하위에 application-oauth.properties 파일을 생성하고 구글 서비스에서 받은 클라이언트 ID와 보안 비밀 코드를 등록한다.

 

'scope=profile,email' 로 설정한 이유는, 대부분 openid, profile, email이 기본값이기에 별도로 scope를 설정하지 않지만 강제로 profile, email을 등록한 이유는 openid라는 scope가 있을 경우 Open Id Provider로 인식하기 때문이다. 만약 openid 마저 설정되어 있다면, OpenId Provider인 구글 서비스와 그렇지 않은 네이버, 카카오 서비스를 나누어서 각각 OAuth2Service를 만들어야 한다. 하나의 OAuth2Service로 사용하기 위해 openid를 제외하여 설정한다.

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

2) application.properties

스프링 부트에서는 properties 이름을 application-OOO.properties 형태로 할 경우,  OOO라는 profile이 생성되어 관리할 수 있다. 즉 profile=OOO로 호출하게 되면, 해당 properties의 설정을 가져올 수 있다. 이 규칙을 이용하여 application.properties에서 application-oauth.properties를 포함하도록 설정한다.

spring.profiles.include=oauth

3) Git

구글 로그인을 위한 클라이언트 ID와 보안 비밀 코드는 중요한 정보들이기에, application-oauth.properties가 푸시되지 않도록 .gitignore 파일에 설정해주어야 한다.

 

 

 

3. 구글 로그인 연동

1) Entity

사용자 정보를 담당할 도메인 User Entity 클래스를 생성해준다.

어노테이션 및 코드 설명
@Enumerated(EnumType.STRING) JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지 결정한다. 기본적으로 int로 저장이된다. 숫자로 저장될 경우 데이터베이스로 확인할 때 그 값이 어떤 코드를 의미하는지 알 수가 없기에 문자열로 저장할 수 있도록 설정한다.
package com.springbook.biz.domain.user;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

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

2) Role

각 사용자의 권한을 관리할 Enum 클래스를 생성한다.

어노테이션 및 코드 설명
ROLE_GUEST, ROLE_USER 스프링 시큐리티에서는 권한 코드에 항상 'ROLE_' 앞에 선언되어야 한다. 따라서 코드별 키 값을 지정한다.
package com.springbook.biz.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;
}

3) Repository

User의 CRUD Repository를 생성한다.

어노테이션 및 코드 설명
Optional<User> findByEmail(String email); 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입자인지 판단하기 위한 메서드이다.
package com.springbook.biz.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);
}

4) build.gradle

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

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

5) SecurityConfig.java

모든 시큐리티 관련 클래스를 담을 config.auth 패키지 하위에 SecurityConfig 클래스를 생성해준다.

어노테이션 및 코드 설명
@EnableWebSecurity 스프링 시큐리티 설정을 활성화해준다.
csrf().disable().headers().frameOptions().disable() h2-console 화면을 사용하기 위해 해당 옵션을 비활성화해준다.
authorizeRequests URL별 권한 관리를 설정하는 옵션의 시작점이다. authorizeRequests가 선언이 되어야 antMatchers 옵션을 사용할 수 있다.
antMatchers 권한 관리 대상을 지정하는 옵션이다. URL, HTTP 메서드별로 관리가 가능하다. permitAll() 옵션을 통해 지정된 URL에 대한 전체 열람 권한을 설정하고, 특정 권한을 가진 사람만 URL에 대한 열람이 가능하도록 설정한다.
anyRequest 설정된 값들 이외의 URL을 뜻한다. authenticated()를 추가할 경우, 나머지 URL들은 모두 인증된 사용자들에게만 허용하도록 설정할 수 있다.
logout().logoutSuccessUrl("/") 로그아웃 기능에 대한 설정이다.
oauth2Login OAuth2 로그인 기능에 대한 설정이다.
userInfoEndpoint OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정이다.
userService 소셜 로그인 성공 시 후속 조치를 취할 UserService 인터페이스의 구현체를 등록한다. 리소스 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.
package com.springbook.biz.config.auth;

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

6) CustomOAuth2UserService.java

구글 로그인 이후 가져온 사용자의 정보를 기반으로 가입 및 정보 수정, 세션 저장 등의 기능을 지원하는 CustomOAuth2UserService 클래스를 생성한다. 구글 사용자 정보가 업데이트 될 때를 대비하여 update 기능도 같이 구현한다.(사용자의 이름이나 프로필 사진 변경 시 User Entity에 반영)

어노테이션 및 코드 설명
registrationId 현재 로그인 진행 중인 서비스를 구분하는 코드이다.
userNameAttributeName OAuth2 로그인 진행 시 키가 되는 필드값이다(PK와 같은 의미). 구글의 경우 기본적으로 코드(sub)를 지원하나 네이버, 카카오 등은 기본 지원하지 않는다.
OAuthAttributes OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스이다.
SessionUser 세션에 사용자 정보를 저장하기 위한 Dto 클래스이다.
package com.springbook.biz.config.auth;

import com.springbook.biz.config.auth.dto.OAuthAttributes;
import com.springbook.biz.domain.user.User;
import com.springbook.biz.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();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

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

        User user = saveOrUpdate(attributes);
        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);
    }
}

7) OAuthAttributes.java

dto 패키지 하위에 OAuthAttributes 클래스를 생성한다.

어노테이션 및 코드 설명
of() OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야한다.
toEntity() User Entity를 생성한다. OAuthAttributes에서 Entity를 생성하는 시점은 처음 가입할 때이다. 가입할 때의 기본 권한을 GUEST로 주도록 한다.
package com.springbook.biz.config.auth.dto;

import com.springbook.biz.domain.user.Role;
import com.springbook.biz.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();
    }
}

8) SessionUser.java

마찬기지로 dto 패키지 하위에 SessionUser 클래스를 생성한다. 이 클래스는 인증된 사용자 정보만 필요하기에 필요한 필드만 선언한다. User 클래스를 세션에 저장하게 될 경우, 직렬화를 구현하지 않았기 때문에 문제가 발생한다. Entity 클래스인 User 클래스에는 다른 Entity들과의 연관 관계가 존재할 수 있고, 그럴 경우 직렬화 대상에 자식들까지 포함되니 성능과 부수 효과 문제가 발생할 수 있다. 따라서 직렬화 기능을 가진 SessionUser 클래스를 생성해야만한다.

package com.springbook.biz.config.auth.dto;

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

 

 

 

4. 로그인 화면

1) index.mustache

어노테이션 및 코드 설명
{{#userName}} Mustache는 if문을 제공하지 않으며, 오직 true/false 여부만 판단한다. 여기서는 userName이 존재할 경우에 대한 내용을 설정한다.
a href="/logout" 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL로, 별도의 Controller 코드가 필요하지 않다. SecurityConfig 클래스에서 URL변경이 가능하다.
{{^userName}} ^ 기호는 값이 존재 하지 않을 경우 사용한다. 즉, userName이 존재하지 않을 경우에 대한 내용을 설정한다.
a href="/oauth2/authorization/google 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL이다. 로그아웃 URL과 동일하게 개발자가 별도의 Controller를 생성할 필요없다.
<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>
            <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
        {{/userName}}
    </div>
</div>

2) Controller

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

어노테이션 및 코드 설명
(SessionUser) httpSession.getAttribute("user") CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성하여 로그인 성공 시 값을 가져올 수 있도록 한다.
package com.springbook.biz.web;

import com.springbook.biz.config.auth.dto.SessionUser;
import com.springbook.biz.service.posts.PostsService;
import com.springbook.biz.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import javax.servlet.http.HttpSession;

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    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";
    }

    ...
}

3) 구현 화면

실제로 구동하였을 때 로그인 화면, 동의 화면, 로그인 성공 화면을 확인하고 데이터베이스에 정상적으로 회원정보가 입력이 되었는지 확인한다. 로그인된 사용자의 권한은 GUEST이며, 글 등록 기능을 사용하기 위해서는 h2-console에서 GUEST 권한을 USER로 변환하여 글 등록 기능까지 확인한다.

구현 화면


[참고] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스

[참고] ai-creator.tistory.com/9

728x90