본문 바로가기

Programming/SpringBoot

[SpringBoot] 간단한 게시판 만들기 #6 - SpringSecurity 개념 및 구현

반응형

SpringSecurity를 이용하여 Google OAuth2 Login을 구현해보도록 한다.

 

config 패키지에는 SessionUser와 SecurityConfig 클래스를 작성해주고,

Index (/) 경로로 들어오는 Client만 허용하도록 BaseController에 해당 분기를 설정한다.

 

또한 domain은 Role(일반 사용자, 손님) Entity와 User Entity를 작성하고,

Service로는 CustomOAuth2UserService를 작성하여 비즈니스 로직을 구현해준다.

 

의존성

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

 

SpringSecurity 설정

SpringSecurity는 FilterChainProxy라는 이름으로 내부에 여러 Filter들이 동작하고 있다.

 

별도의 Logic을 작성하지 않고 설정만으로 로그인/로그아웃 등의 처리가 가능하다.

 

또한 OAuth2 인증 방식을 Google, Kakao, Naver, Facebook, Github 등을 통해서 타 사이트의 계정으로도 Login이 가능하다.

 

본 글에서는 OAuth2 Google 로그인을 구현해 볼 예정이다.

 

다음의 순서대로 설명을 진행해 볼 예정이다.

 

0. Google 서비스 등록

1. application-oauth2.yml 작성

2. SecurityConfig : 스프링 시큐리티 설정

3. Role - Enum 클래스

4. User 클래스

5. OAuthAttributes 클래스 : DTO

6. CustomOAuth2UserService : OAuthAttributes를 기반으로 해당 웹 사이트 및 정보 수정, 세션 저장 등

7. SessionUser 클래스 : User Entity 클래스에서 직렬화가 필요한 경우 별도 사용

8. BaseController


SpringSecurity로 OAuth2 Google 인증

- OAuth2 Authorization : Client가 서비스 제공자(카카오, 구글, ..)로부터 회원 리소스를 제공받기 위해 인증 및 권한 부여를 받는 일련의 절차

 

 

 

0. 구글 서비스 등록

client-id와 client-secret을 GCP에서 발급 받아 저장해둔다.

 

 

1. application-oauth2.yml

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: xxxxxx-xxxxx.apps.googleusercontent.com
            client-secret: XXXXXX-xxxx-XXXXXXXX-XXXXXXX
            scope: email,profile

 

++ .gitignore 설정

크레덴셜 정보가 있기 때문에 gitignore에 추가해줘야 함

 

 

 

2. SecurityConfig 파일 작성

package shop.pingping2.board.config;


import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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;
import shop.pingping2.board.domain.Role;
import shop.pingping2.board.service.CustomOAuth2UserService;



@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("/", "/Savory-gh-pages/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/")
                .and()
                .logout().logoutSuccessUrl("/")
                .and()
                .oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);

    }
}

 

 

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomOAuth2UserService customOAuth2UserService;

@EnableWebSecurity
- Spring Security 설정들을 활성화하기 위한 애노테이션이다.

 

.csrf().disable().headers().frameOptions().disable()

- h2-console 화면을 사용하기 위해 해당 옵션들을 disable

 

authorizeRequests

- URL별 권한 관리를 설정하는 옵션의 시작점

- authorizeRequests가 선언되어야만 antMatchers 옵션을 사용이 가능하다.

 

antMatchers

- 권한 관리 대상을 지정하는 옵션이다.

- URL, HTTP 메서드별로 관리가 가능하다.

 

.anyRequest().authenticated()

- 설정된 값을 이외 나머지 URL들을 나타낸다.

- authenticated() 메서드를 통해 나머지 URL들은 모두 인증된 사용자에게만 허용하게 한다.

 

.oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);

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

- userInfoEndpoint : OAuth2 로그인이 성공된 이후 사용자 정보를 가져올 때 설정을 담당한다.

- userService

  : 소셜 로그인 성공 시 후속 조치를 진행 할 UserService 인터페이스의 구현체를 등록한다.

  : 서비스 제공자(Google, Kakao, ..)에서 사용자 정보를 가져온 정보 (Email, Name, Picture, ..)을 기반으로 추가로 진행하고자 하는 기닝들을 명시할 수 있다.

  (예를 들어, 해당 정보를 가지고 우리 웹 서비스의 DB에 사용자들을 저장한다든지 등등..) 


3. Role - Enum 클래스

사용자의 권한을 Enum 클래스로 만들어 관리한다.

package shop.pingping2.board.domain;

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

4. User 클래스 작성

package shop.pingping2.board.domain;

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

import javax.persistence.*;

@Getter
@Entity
@Table(name = "user")
@NoArgsConstructor(access = AccessLevel.PROTECTED)  // 외부에서의 생성을 열어 둘 필요가 없을 때 / 보안적으로 권장된다.
public class User extends Time{

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

5. OAuthAttributes 클래스 : DTO

: 구글 로그인 이후 가져온 사용자의 이메일, 이름, 프로필 사진 주소를 저장하는 DTO

package shop.pingping2.board.dto;


import lombok.Builder;
import lombok.Getter;
import shop.pingping2.board.domain.Role;
import shop.pingping2.board.domain.User;

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

6. CustomOAuth2UserService : OAuthAttributes를 기반으로 해당 웹 사이트 및 정보 수정, 세션 저장 등

OAuthAttributes을 기반으로 가입 및 정보수정, 세션 저장 등 기능 수행

package shop.pingping2.board.service;

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 shop.pingping2.board.config.auth.SessionUser;
import shop.pingping2.board.domain.User;
import shop.pingping2.board.dto.OAuthAttributes;
import shop.pingping2.board.repository.UserRepository;

import javax.servlet.http.HttpSession;
import javax.transaction.Transactional;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Transactional
@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);

        // OAuth2 서비스 id (구글, 카카오, 네이버)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        // OAuth2 로그인 진행 시 키가 되는 필드 값(PK)
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        // OAuth2UserService
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
        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);
    }
}

 

 

// OAuth2 서비스 id (구글, 카카오, 네이버)
String registrationId = userRequest.getClientRegistration().getRegistrationId();

현재 로그인 진행 중인 서비스를 구분하는 코드이다.

 

본 글은 Google로만 Login을 진행하기 때문에 필요가 없을 수 있지만 추후 Naver, Kakao 등 소셜 로그인이 추가될 때 어떤 소셜 제공자인지 구분하기 위한 부분이다.

 

 

// OAuth2 로그인 진행 시 키가 되는 필드 값(PK)
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

- OAuth2 로그인 진행 시 키가 되는 필드값. Primary Key와 같은 의미

 

 

// OAuth2UserService
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user)); // SessionUser (직렬화된 dto 클래스 사용)

- OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 attributes란 DTO로 담는다.

- 이후, saveOrUpdate 메서드의 인자로 attributes를 넣어준다.

- 이후, 세션에 사용자 정보를 저장하기 위해 SessionUser 클래스에 인자로 user를 넣어서 httpSession의 Attribute를 추가해준다.

  => 세션에 저장하기 위해 기 생성한 User Class를 세션에 저장하려고 하면 User 클래스에 직렬화를 구현하지 않았다는 에러가 발생하여 따로 SessionUser를 구현

 

// 유저 생성 및 수정 서비스 로직
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. SessionUser 클래스 : User Entity 클래스에서 직렬화가 필요한 경우 별도 사용

package shop.pingping2.board.config.auth;

import shop.pingping2.board.domain.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 클래스가 이미 있는데 왜 따로 SessionUser 클래스를 생성하였을까?

 

- Entity 클래스는 직렬화 코드를 넣지 않는게 좋다
- Entity 클래스에는 언제 다른 Entity와 Relationship이 형성될지 모른다.
- @OneToMany, @ManyToMany등 자식 Entity를 갖고 있다면 직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높다

 

=> 그래서 직렬화 기능을 가진 세션 DTO를 하나 추가로 만든 것이 더 좋은 방법이다.


8. BaseController

package shop.pingping2.board.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import shop.pingping2.board.config.auth.SessionUser;

import javax.servlet.http.HttpSession;

@RequiredArgsConstructor
@Controller
public class BaseController {

    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model){

        SessionUser user = (SessionUser) httpSession.getAttribute("user");

        if(user != null) {
            model.addAttribute("userName", user.getName());
            model.addAttribute("userImg", user.getPicture());
        }

        return "index";
    }
}

 

(SessionUser) httpSession.getAttribute("user")

 

=> 앞서 작선된 CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성

=> 즉, 로그인 성공시 httpSerssion.getAttribute("user")에서 값을 가져올 수 있다.

 

user가 null 값일 경우 index 페이지에 model 값에 attribute를 전달하기 못할 것이다.


로그인 테스트

 

1. Index 페이지는 접근 가능

 

2. Index 페이지 외에 다른 페이지 접근

: 로그인 페이지로 리디렉션

 

3. Google로 OAuth2 로그인 수행

 

4. 로그인 후 게시판에 정상 접근이 가능해짐

 

 

지금까지 Spring Security로 OAuth2 google 로그인을 겉핧아보았다.

다음 시간에는 Front View 부분까지 완성 후 최종 확인해 볼 예정이다.

 

 

Ref

Jojoldu님 책

http://www.yes24.com/Product/Goods/83849117

http://yoonbumtae.com/?p=2652

https://daddyprogrammer.org/post/1239/spring-oauth-authorizationserver/

 

 

 

반응형