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

짱J·2022년 6월 28일
22
post-thumbnail
post-custom-banner

스프링 시큐리티(Spring Security)는 막강한 인증(Authentication)과 인가(Authorization) 기능을 가진 프레임워크이다.

🧸 인증과 인가의 차이 : https://ivorycode.tistory.com/entry/인증Authentication과-인가Authorization

사실상 스프링 기반의 애플리케이션에서는 보안을 위한 표준이다.
인터셉터와 필터 기반의 보안 기능을 구현하는 것보다 스프링 시큐리티를 통해 구현하는 것을 권장하고 있다.

스프링 시큐리티와 OAuth 2.0을 구현한 구글 로그인을 연동하여 로그인 기능을 만들어보자 !


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

많은 서비스에서 로그인 기능을 아이디/비밀번호 방식보다는 구글,페이스북, 네이버 로그인과 같은 소셜 로그인 기능을 사용한다.

소셜 로그인을 사용하지 않으면 직접 구현해야 하는 것들이 많아지기 때문이다.
아래는 OAuth를 사용하지 않았을 때 구현해야 하는 것들이다. (OAuth를 써도 구현해야 하는 것 제외)

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

OAuth로 로그인을 구현하면 위 목록들을 구글, 페이스북, 로그인에 맡기면 되므로 서비스 개발에 집중할 수 있게 된다.


구글 서비스 등록

구글 소셜 로그인을 사용하기 위해서는 먼저 신규 서비스를 생성하여야 한다.
여기서 발급된 인증 정보(clientId & clientSecret)를 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있으니 !꼭! 발급받고 시작하여야 한다.

  1. 구글 클라우드 플랫폼 주소로 들어간다
    https://console.cloud.google.com
  1. 프로젝트 선택 > 새 프로젝트 > 원하는 이름으로 프로젝트 생성
  1. API 및 시버스 > 사용자 인증 정보 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID
  1. 동의 화면 구성 > OAuth 동의 화면 > 만들기
    4-1. 앱 이름, 사용자 지원 이메일, 개발자 작성
    4-2. 범위 추가 또는 삭제 > email, profile, openid 선택 (기본 범위) > 저장 후 계속
    4-3. (테스트 사용자 skip) 저장 후 계속
  1. 다시 사용자 인증 정보 만들기로 돌아가서 > OAuth 클라이언트 ID > 유형 선택하고 이름 작성, 승인된 리디렉션 URI 추가 > 만들기

🪴 승인된 리디렉션 URI
- 서비스에서 파라미터로 인증 정보가 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL
- 스프링 부트 2 버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}
- 별도록 리다이렉트 URL을 지원하는 Controller을 만들 필요 없음

사용자 인증 정보에서 클라이언트 정보와 인증 정보를 확인할 수 있다.


프로젝트 설정

application-oauth 등록

application.properties가 있는 디렉토리에 application-oauth.properties 파일을 생성한다.

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
  • 강제로 profile, email를 등록한 이유 - openid라는 scope이 있으면 OpenId Provider로 인식하기 때문
    • 그렇게 된다면 OpenId Provider인 서비스(구글)와 그렇지 않은 서비스로 나눠서 OAuth2Service를 만들어야 함

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

기본 설정 파일인 application.properites에서 application-oauth.properties를 포함하도록 구성하자.
application.properites에 아래 코드를 추가한다.

spring.profiles.include=oauth

.gitingore 등록

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

보안을 위해 application-oauth.properties 파일이 깃허브에 올라가지 않도록, .gitignore 파일을 수정해주자.


구글 로그인 연동하기

앞선 과정으로 구글의 로그인 인증 정보를 발급 받고, 프로젝트 설정을 마쳤다.

본격적으로 프로젝트 구현을 진행해보자.

먼저, domain에 user 패키지를 생성하고, 사용자의 권한을 관리할 Enum 클래스 Role을 생성한다.

<package com.example.demo.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_이 앞에 있어야만 한다!

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

package com.example.demo.domain.user;

import com.example.demo.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();
    }
}

🐬 @Enumerated(EnumType.STRING)

  • JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정 (기본 - int로 된 숫자)
  • 숫자로 저장되면 데이터베이스로 확인할 때 무슨 코드를 의미하는지 알 수 없으므로 문자열로 저장될 수 있도록 선언하자.

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

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

User 엔티티 관련 코드는 완료되었고, 다음으로 시큐리티 설정을 진행해보자.


스프링 시큐리티 설정

먼저 build.gradle에 스프링 시큐리티 관련 의존성을 하나 추가하자.
소셜 기능 구현 시 필요한 의존성으로, spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

다음으로, OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성하자.


config.auth 패키지를 생성한다. 앞으로 시큐리티 관련 클래스는 모두 이 곳에 담는다.

SecurityConfig → CustomOAuth2UserService → OAuthAttribute, SessionUser

화살표는 클래스 간 계층을 나타낸 것으로 SecurityConfig 클래스 안에서 CustomOAuth2UserService가 사용되고, 그 안에서 OAuthAttribute가 쓰인다.

나는 코드를 작성하며 빨간 줄이 뜨는게 거슬리기 때문에! 역순으로 코드를 작성하겠다.
코드가 많이 길어졌기 때문에 심호흡 시작하구 타이핑하자
후 ~ 하 ~

OAuthAttribute, SessionUser은 config.auth 패키지 안에 dto 패키지를 생성하여 그 안에 만든다.

🐠 OAuthAttribute

package com.example.demo.config.auth.dto;

import com.example.demo.domain.user.Role;
import com.example.demo.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이기 때문에 값 하나하나를 변환하여야 한다.

🏝 Map이란? key를 이용하여 value를 얻을 수 있는 자료형
자세한 내용은 https://wikidocs.net/208 를 참고하자.

🐬 toEntity

  • User Entity를 생성
  • OAuth에서 엔티티를 생성하는 시점은 💥처음 가입할 때💥
  • 가입할 때 기본 권한으로 GUEST를 주기 위해 role 빌더 값으로 Role.GUSET를 사용

🐠 SessionUser

package com.example.demo.config.auth.dto;

import com.example.demo.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에는 인증된 사용자 정보만 필요하다.

🐠 CustomOAuth2UserService

package com.example.demo.config.auth;

import com.example.demo.config.auth.dto.OAuthAttributes;
import com.example.demo.config.auth.dto.SessionUser;
import com.example.demo.domain.user.User;
import com.example.demo.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와 같은 의미
  • 구글의 경우 기본적으로 코드를 지원하지만, 네이버나 카카오 등은 기본 지원하지 않음
  • 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용

🐬 OAuthAttributes

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

🐬 SessionUser

  • 세션에 사용자 정보를 저장하기 위한 DTO 클래스
  • User 클래스를 그대로 사용하면, User 클래스는 엔티티이기 때문에 언제 다른 엔티티와 관계가 형성될지 모른다.
    User 클래스에 직렬화 코드를 넣으면 직렬화 대상에 자식들까지 포함되어 성능 이슈, 부수 효과가 발생할 확률이 높아진다.
    그러므로 직렬화 기능을 가진 세션 DTO를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 많은 도움이 된다.

🐠 SecurityConfig

package com.example.demo.config.auth;

import com.example.demo.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 메소드별로 관리가 가능
  • "/" 등 지정된 URL은 permitAll() 옵션으로 전체 열람 권한 부여
  • "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능

🐬 anyRequest

  • 설정된 값들 이외 나머지 URL을 나타냄
  • authenticated()를 추가하여 나머지 URL들은 모두 인증된 사용자(로그인한 사용자)들에게만 허용

🐬 logout().logoutSuccessUrl("/")

  • 로그아웃 기능에 대한 여러 설정의 진입점
  • 로그아웃 성공 시 "/" 주소로 이동

🐬 oauth2Login

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

🐬 userInfoEndpoint

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

🐬 userService

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

로그인 테스트

브라우저에서 스프링 시큐리티가 잘 적용되었는지 확인해보자.
index.mustache에 로그인 버튼과 로그인 성공 시 사용자 이름을 보여주는 코드를 글 등록 태그 아래에 추가하자.

{{#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}}

🐬 {{#userName}}

  • 머스테치는 다른 언어와 같은 if문을 제공하지 않고 true/false 여부만 판단하므로, 항상 최종값을 넘겨줘야 함
  • userName이 있다면 userName을 노출시키도록 구성

🐬 "/logout"

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

🐬 {{^userName}}

  • 해당 값이 존재하지 않는 경우에는 '^'을 사용

🐬 "/ouaht/authorization/google

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

그 다음, index.mustache에서 userName을 사용할 수 있게 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")

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

🐬 if (user != null)

  • 세션에 저장된 값이 있을 때만 model에 userName으로 등록
  • 저장된 값이 없으면 model엔 아무런 값이 없는 상태이므로 로그인 버튼만 보임

이제 프로젝트를 실행해서 테스트해보자.

굿 !!

h2-console에서도 데이터베이스에 회원 정보가 정상적으로 들어간 것을 확인할 수 있다.

권한 관리도 잘 되는지 확인해보자. 현재 로그인된 사용자의 권한은 GUEST로, posts 기능을 쓸 수 없어야 한다.

실제로 테스트해보면 403(권한 거부) 에러가 발생한 것을 볼 수 있다. (정상임 !!)

이렇게 기본적인 구글 로그인, 로그아웃, 회원가입, 권한관리 기능이 모두 구현되었다!

글이 길어지고 있어 어노테이션 기반 코드 리팩토링과 네이버 로그인 구현은 다음 포스트에 기록하겠다 :)

profile
[~2023.04] 블로그 이전했습니다 ㅎㅎ https://leeeeeyeon-dev.tistory.com/
post-custom-banner

0개의 댓글