Gom3rye

스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 본문

졸업 프로젝트

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

Gom3rye 2022. 11. 15. 11:47

Viimpt의 로그인 보안성을 생각해 로그인 기능은 소셜 로그인을 이용해 구현하기로 했다.

1. 구글 서비스에 신규 서비스 생성하기

구글 클라우드 플랫폼 주소(https://console.cloud.google.com)로 이동한 후 '프로젝트 선택' 클릭 > '새 프로젝트' 클릭

등록될 서비스 이름을 입력한다.

그렇게 생성 완료된 프로젝트를 선택하고 왼쪽 메뉴 탭을 클릭해서 API 및 서비스 카테고리로 이동 > '사용자 인증 정보'를 클릭하고 '사용자 인증 정보 만들기' 클릭

사용자 인증 정보의 여러 메뉴 중 OAuth 클라이언트 ID를 클릭한다.

클라이언트 ID가 생성되기 전에 동의 화면 구성이 필요하므로 '동의 화면 구성' 버튼 클릭

만들기 클릭 후 앱 정보를 등록한다.

앱 이름은 구글 로그인 시 사용자에게 노출될 앱 이름이고 지원 이메일은 사용자 동의 화면에서 노출될 이메일 주소이다. 보통은 서비스의 help 이메일 주소를 사용하지만, 여기서는 나의 이메일 주소를 사용했다.

Google API의 범위는 이번에 등록할 구글 서비스에서 사용할 범위 목록으로 email, profile, openid로 클릭한 후 '업데이트' 버튼을 누른다.

동의 화면 구성이 끝났으면 화면 제일 아래에 '저장' 버튼을 클릭하고 OAuth 클라이언트 ID 만들기 화면으로 이동한다. 애플리케이션 유형으로는 웹 애플리케이션을 클릭한 후 이름을 작성해준다.

승인된 리디렉션 URI는 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL 이다. 현재는 개발 단계이므로 http://localhost:8080/login/oauth2/code/google로만 등록한다.
- 스프링 부트 2버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원하고 있다.
AWS 서버에 배포하게 되면 localhost 외에 추가로 주소를 추가하면 된다.

'만들기'를 클릭하면 다음과 같이 OAuth 클라이언트가 생성된 것을 확인할 수 있다.

2. application-oauth.properties 파일 생성 후 방금 만든 클라이언트 ID와 클라이언트 보안 비밀 코드 등록하기

# registration 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

- 하나의 OAuth2Service로 사용하기 위해 일부러 openid scope를 빼고 등록한다.

스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다. 즉, profile=xxx라는 식으로 호출되면 해당 properties의 설정들을 가져올 수 있다. 따라서 스프링 부트의 기본 설정 파일인 application.properties에서 application-oauth.properties를 포함하도록 application.properties 파일에 다음과 같이 코드를 추가한다.

spring.profiles.include=oauth

이제 이 설정값을 사용할 수 있다.

3.  .gitignore 등록

구글 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀은 보안이 중요한 정보들이므로 application-oauth.properties 파일이 깃허브에 올라가지 않도록 .gitignore 파일에 다음과 같이 코드를 추가한다.

application-oauth.properties

4. 사용자 정보를 담당할 도메인인 User 클래스 생성

domain 패키지 아래 user 패키지를 생성한 후 거기에 아래의 코드를 작성한다.

package com.gom3rye.book.springboot.domain.user;

import com.gom3rye.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) // JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정한다. (기본적으로는 int로 된 숫자가 저장되어 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();
    }
}

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

package com.gom3rye.book.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;
}

스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 하므로 코드별 키 값을 ROLE_GUEST, ROLE_USER로 지정했다.

마지막으로 User의 CRUD를 책임질 UserRepository도 생성한다.

package com.gom3rye.book.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); // 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입한 사용자인지 판단하기 위한 메소드
}

5. 스프링 시큐리티 설정

build.gradle 에 스프링 시큐리티 관련 의존성 하나를 추가한다.

implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
// 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성
// spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.

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

domain 패키지와 같은 위치에 config.auth 패키지를 생성 후 시큐리티 관련 클래스를 모두 이곳에 담는다.

SecurityConfig 클래스를 생성하고 다음과 같이 코드를 작성한다.

package com.gom3rye.book.springboot.config.auth;

import com.gom3rye.book.springboot.domain.user.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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; //WebSecurityConfigurerAdapter는 deprecated 되어, 상속을 받지 않고 모두 Bean으로 등록하여 사용하는 방식으로 변경 됨.
import org.springframework.security.web.SecurityFilterChain;

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity // Spring Security 설정들을 활성화시켜 준다.
public class SecurityConfig {
    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .headers().frameOptions().disable() // h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.
                .and()
                .authorizeHttpRequests(authorize -> authorize
                        .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
                        .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                        .anyRequest().authenticated()) // anyRequest : 설정된 갓들 이외 나머지 URL들을 나타냄
                // 여기서는 authenticated()를 추가해 나머지 URL들은 모두 인증죈 사용자들에게만 허용하게 한다. 인증된 사용자 = 로그인한 사용자
                .logout(logout -> logout
                        .logoutSuccessUrl("/")) // 로그아웃 기능에 대한 여러 설정의 진입점, 로그아웃 성공 시 / 주소로 이동한다.
                .oauth2Login(oauth2Login -> oauth2Login
                        .userInfoEndpoint() // OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당
                        .userService(customOAuth2UserService)); // 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록, 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.

        return http.build();
    }
}

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

package com.gom3rye.book.springboot.config.auth;

import com.gom3rye.book.springboot.config.auth.dto.OAuthAttributes;
import com.gom3rye.book.springboot.config.auth.dto.SessionUser;
import com.gom3rye.book.springboot.domain.user.User;
import com.gom3rye.book.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(); // registrationId : 현재 로그인 진행 중인 서비스를 구분하는 코드
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 진행 시 키가 되는 필드값을 이야기한다. Primary Key와 같은 의미

        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes()); //  OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스

        User user = saveOrUpdate(attributes);
        httpSession.setAttribute("user", new SessionUser(user)); // SessionUser : 세션에 사용자 정보를 저장하기 위한 Dto 클래스

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

구글 사용자 정보가 업데이트 되었을 때를 대비해 update 기능도 같이 구현해서 사용자의 이름이나 프로필 사진이 변경되면 User 엔티티에도 반영된다.

config.auth.dto 패키지를 만들어 OAuthAttributes 클래스를 생성해 다음과 같이 코드를 작성한다.

package com.gom3rye.book.springboot.config.auth.dto;

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

    // OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야만 한다.
    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
        if("naver".equals(registrationId)) {
            return ofNaver("id", 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();
    }

    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    // toEntity: User 엔티티를 생성, OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때이다.
    // 가입할 때 기본 권한을 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST를 사용한다.
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

config.auth.dto 패키지에 SessionUser 클래스를 추가한 후 다음과 같이 코드를 작성한다.

package com.gom3rye.book.springboot.config.auth.dto;

import com.gom3rye.book.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에는 인증된 사용자 정보만 필요하고 그 외 필요한 정보들은 없으니 name, email, picture만 필드로 선언한다.

<< 중요 >>

User 클래스를 사용하지 않고 SessionUser 클래스를 만든 이유

: User 클래스를 그대로 사용했다면 Failed to convert from type [java.lang.Object] to type [byte[]] for value 'com.gom3rye.book.springboot.domain.user.User@4a43d6' 과 같은 에러가 난다.
-> 세션에 저장하기 위해 User 클래스를 세션에 저장하려고 하니 User 클래스에 직렬화를 구현하지 않았다는 의미의 에러이다.
-> User 클래스에 직렬화 코드를 넣으면 해결?
-> 답은 X, User 클래스가 엔티티이기 때문에 언제 다른 엔티티와 관계가 형성될 지 모르기 때문이다. @OnetoMany, @ManyToMany 등 자식 엔티티를 갖고 있다면 직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높다. 

-> 따라서 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 많은 도움을 준다.

7. 끝!

프로젝트를 실행 후 구글 로그인을 하면 평소 다른 서비스에서 볼 수 있던 것처럼 구글 로그인 동의 화면으로 이동하고 본인의 계정을 선택하면 로그인 과정이 진행된다. 로그인이 성공하면 구글 계정에 등록된 이름이 화면에 노출된다!

728x90
반응형