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

vencott·2021년 9월 9일
0

스프링 시큐리티는 막강한 인증(Authentication)인가(Authorization)기능을 가진 프레임워크로 사실상 스프링 기반 애플리케이션에서 보안을 위한 표준이다

이번 장에서는 스프링 시큐리티와 OAuth 2.0을 구현한 구글 로그인을 연동하여 로그인 기능을 만들어본다

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

많은 서비스에서 로그인 기능을 id, pw 방식보단 구글, 페이스북, 네이버 로그인과 같은 소셜 로그인 기능을 사용한다

직접 구현할 경우 로그인 시 보안, 이메일/전화번호 인증, 비밀번호 찾기/변경 등 구현해야 할 기능이 많아 배보다 배꼽이 더 커지기 때문이다

스프링 부트 1.5 vs 2.0

스프링 부트 2.0에서 OAuth2 연동 방법이 크게 바뀌었는데, 설정 방법에 크게 차이가 없는 경우를 자주 볼 수 있다

이는 spring-security-oauth2-autoconfigure 라이브러리 덕분인데, 1.5에서 쓰던 설정을 2에서도 그대로 사용할 수 있게 매칭해준다

하지만 이 책에서는 스프링부트 2 방식인 Spring Security Oauth2 Client를 사용한다

  • 스프링 부트 1.5 방식에서는 url 주소를 모두 명시해야 하지만, 2.0 방식에서는 client 인증 정보만 입력하면 된다
  • CommonOAuth2Provider라는 enum이 새롭게 추가되어 구글, 깃허브, 페이스북의 기본 설정값은 모두 여기서 제공한다

5.2 구글 서비스 등록

구글 서비스 설정

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

API및 서비스 > 사용자 인증 정보 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID

먼저, 다음과 같은 범위의 동의 화면을 구성한다

OAuth 클라이언트 ID를 생성한다

승인된 리디렉션 URI

  • 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL
  • 스프링 부트 2 버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원하고 있다
  • 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요가 없다(시큐리티에서 이미 구현)

성공적으로 생성되었다면, 클라이언트 ID와 클라이언트 보안 비밀을 프로젝트에 설정한다

application-oauth 등록

src/main/resources 디렉토리에 application-oauth.properties 파일을 생성하고 클라이언트 ID와 보안 비밀을 입력한다

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=profile,email

  • 기본값이 openid,profile,email 이므로 많은 예제에서는 이 문장을 넣지 않는다
  • 강제로 profile,email을 등록한 이유는 openid라는 scope가 있으면 Open Id Provider로 인식하기 때문이다
  • 이렇게 되면 Open Id Provider인 서비스(구글)와 아닌 서비스(네이버, 카카오 등)로 나눠서 각각 OAuth2Service를 만들어줘야 하므로 하나의 OAuth2Service로 사용하기 위해 명시해준다

스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다

즉, profile=xxx 와 같이 호출하면 해당 properties의 설정들을 가져올 수 있다

여기선 스프링 부트의 기본 설정 파일인 application.propertiesapplication-oauth.properties를 포함하도록 구성한다

application.properties

spring.profiles.include=oauth

.gitignore 등록

구글 로그인을 위한 클라이언트 ID와 보안 비밀은 보안적으로 중요한 정보들이므로 gitignore에 추가해준다

# Project exclude paths
.gradle
.idea
application-oauth.properties

5.3 구글 로그인 연동하기

User.java

package com.vencott.dev.springboot.domain.user;

import com.vencott.dev.springboot.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)
    @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();
    }
}

먼저, 사용자 정보를 담당할 도메인인 User 클래스를 생성한다

@Enumerated(EnumType.STRING)

  • JPA로 DB에 저장할 때 Enum값을 어떤 형태로 저장할 지 결정
  • 디폴트는 int로 된 숫자형

Role.java

package com.vencott.dev.springboot.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;
}

다음은 각 사용자의 권한을 관리할 Enum 클래스 Role을 생성한다

스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야 한다

UserRepository.java

package com.vencott.dev.springboot.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을 통해 이미 생성된 사용자인지, 처음 가입하는 사용자인지 판별하기 위한 메소드

스프링 시큐리티 설정

먼저 build.gradle에 스프링 시큐리티 관련 의존성을 추가한다

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

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

시큐리티 관련 클래스를 담는 config.auth 패키지를 생성하고 SecurityConfig 클래스를 생성한다

SecurityConfig.java

package com.vencott.dev.springboot.config.auth;

import com.vencott.dev.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별 권한 관리를 설정하는 옵션의 시작점

antMatchers()

  • 권한 관리 대상을 지정하는 옵션
  • URL, HTTP 메소드별로 관리 가능
  • api는 USER 권한을 가진 사람만 가능하게 설정

anyRequest().authenticated()

  • 설정된 값 이외 나머지 URL들은 인증된 사용자(로그인 한 사람)에게만 허용

logout().logoutSuccessUrl("/")

  • 로그아웃 성공 시 / 주소로 이동

oauth2Login()

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

userInfoEndPoint()

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

userService()

  • 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록
  • 소셜 서비스에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시

CustomOAuth2UserService.java

package com.vencott.dev.springboot.config.auth;

import com.vencott.dev.springboot.domain.user.User;
import com.vencott.dev.springboot.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);
    }
}

registrationId

  • 현재 로그인 진행 중인 서비스를 구분하는 코드
  • 구글, 네이버 로그인 등 구분

userNameAttributeName

  • OAuth2 로그인 진행 시 키가 되는 필드값(Primary Key)
  • 구글의 경우 기본적으로 "sub"라는 키를 제공

OAuthAttributes

  • OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담은 클래스
  • 이후 다른 소셜 로그인도 이 클래스를 사용

SessionUser

  • 세션에 사용자 정보를 저장하기 위한 Dto 클래스
  • User 클래스를 쓰지 않고 새로 만들어서 쓰는 이유는 뒤이어서 상세히 설명

saveOrUpdate()

  • 구글 사용자 정보가 업데이트 되었을 때를 대비
  • 사용자의 이름이나 사진이 변경되면 User 엔티티에도 반영

OAuthAttributes는 Dto로 취급하기 때문에 config.aut.dto 패키지에 생성

OAuthAttributes.java

package com.vencott.dev.springboot.config.auth.dto;

import com.vencott.dev.springboot.domain.user.Role;
import com.vencott.dev.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);
    }

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

of()

  • OAuth2User에서 반환하는 사용자 정보는 Map이기 때뭉네 값 하나하나를 변환해야 함

toEntity()

  • User 엔티티를 생성
  • OAuthAttributes 에서 엔티티를 생성하는 경우는 처음 가입할 때
  • 기본 권한은 GUEST로 준다

SessionUser.java

package com.vencott.dev.springboot.config.auth.dto;

import com.vencott.dev.springboot.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();
    }
}

SessionUser에는 인증된 사용자 정보만 필요하므로 3가지 필드만 선언한다

User 클래스 대신 SessionUser를 새로 생성한 이유

  • User 클래스를 그대로 사용한다면 직렬화를 구현하지 않았다는 오류가 뜬다
  • User 클래스는 엔티티이기 때문에 언제 어떻게 다른 엔티티와 관계가 형성될 지 모르는데, 직렬화를 구현할 경우 성능 이슈가 발생활 확률이 높다
  • 따라서 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 좋다

로그인 테스트

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

index.mustache

    <!--로그인 기능 영역-->
    <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>

{{#userName}}

  • 머스테치는 다른 언어와 마찬가지로 if문을 사용할 수 없다
  • true/false 여부만 판단하므로 머스테치에는 항상 최종값을 넘겨줘야 한다
  • userName이 있으면 해당 코드블록을 노출시킨다

{{^userName}}

  • 머스테치에서 해당 값이 존재하지 않으면 ^를 사용한다
  • userName이 없으면 해당 코드블록을 노출시킨다

a href="/logout"

  • 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL
  • 개발자가 따로 컨트롤러를 만들 필요가 없다

a href="/oauth2/authorization/google"

  • 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL
  • 개발자가 따로 컨트롤러를 만들 필요가 없다

IndexController.java

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

index.mustache에 userName을 넘겨줄 수 있도록 IndexController를 수정한다

(SessionUser) httpSession.getAttribute("user")

  • 앞서 CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했었다
  • 로그인 성공 시 httpSession.getAttribute("user")로 값을 가져올 수 있다

if (user != null)

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

테스트

애플리케이션을 실행시켜 정상 작동하는지 확인한다

현재 로그인된 사용자의 권한은 GUEST(기본)이므로 posts 기능을 쓸 수 없다(403 에러)

h2-console에서 ROLE을 USER로 UPDATE한 뒤 재로그인해서 글이 등록되는지 확인한다


출처: 이동욱 저, 『스프링 부트와 AWS로 혼자 구현하는 웹 서비스』, 프리렉(2019)


profile
Backend Developer

0개의 댓글

관련 채용 정보