본문 바로가기

Web Dev

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

스프링 시큐리티 : 스프링 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크

-> 인터셉터, 필터 기반의 보안 기능을 구현하기보다 확장성을 위해 스프링 시큐리티를 통해 구현하는 것을 권장.

 

CommonOAuth2Provider: 스프링부트 2.0방식으로 넘어오며 기본 설정값을 enum(열거형)으로 저장함

-> 구글, 페이스북, 옥타, 깃허브만 해당. 네이터, 카카오 등 다른 소셜 로그인을 추가한다면 직접 추가해야함.

1️⃣ 구글 로그인 연동하기 

📜사용자 정보 담당 도메인 - User

User class - 사용자 정보 담당

// 생략 
@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형으로 저장되는데, 숫자로 저장할 시 데이터베이스로 확인할 때 무슨 코드를 의미하는지 알 수 없으므로 문자열(EnumType.STRING)로 저장될 수 있도록 선언한다.

Role - 각 사용자의 권한 관리

// 생략
@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;

}

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

UserRepository -  User의 CRUD

// 생략
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 class

// import 생략
@RequiredArgsConstructor
@EnableWebSecurity		// Spring Security 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
        		// h2-console 화면 사용을 위해 옵션 disable
                .csrf().disable()
                .headers().frameOptions().disable()
                
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout()
                    	// 로그아웃 성공 시 / 주소로 이동
                        .logoutSuccessUrl("/")
                .and()
                    .oauth2Login()
                        .userInfoEndpoint()
                            .userService(customOAuth2UserService);
    }
}
authorizeRequests
- URL별 권한 관리를 설정하는 옵션의 시작점. 선언 후 antMatchers 옵션 사용 가능

antMatchers
- 권한 관리 대상을 지정하는 옵션
- URL, HTTP 메소드 별로 관리 가능

ex )
"/"  지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 줌.
"/api/v1/**"주소를 가진 API는 USER 권한을 가진 사람만 

anyRequest
- 설정된 값들 이외의 나머지 URL들을 나타냄
- authenticated() 인증된(로그인 한) 사용자들에게만 허용함

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

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

userService
- 소셜 로그인 성공 시 진행할 UserService 인터페이스의 구현체(여기서는 customOAuth2UserService)를 등록

CustomOAuth2UserService

- 로그인 후 가져온 사용자의 정보들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능 지원

// import 생략
@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 delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
		
        // 현재 로그인 진행 중인 서비스를 구분하기 위함. 구글인지 네이버인지
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        
        // OAuth2 로그인 진행 시 키가 되는 필드 값. PK와 같은 의미
        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);
    }
}

OAuthAttributes - OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 Dto 클래스.

// import 생략
@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;
    }
	
    // 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();
    }

	// User 엔터티 생성
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

SessionUser - 세션에 인증된 사용자 정보를 저장하기 위한 Dto 클래스

// import 코드 생략
@Getter
// 직렬화(Serializable)를 구현
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 class를 세션 저장에 사용하면 안 될까

user 클래스를 그대로 session 저장에 사용한다면 다음과 같은 에러가 발생한다.

Failed to convert from type [java.lang.Object] to type [byte[]] for value 'com.jojoldu.book.springboot.domain.user.User@4a43d6'

이는 User 클래스를 세션에 저장하려고 하니, User 클래스에 직렬화를 구현하지 않았다는 의미이다.

직렬화: 자바 시스템 내부에서 사용되는 객체(Object) 또는 Data를 외부 자바 시스템에서도 사용할 수 있도록 byte형태로 데이터를 변환하는 기술

 

그렇다면 일차원적인 해결 방법으로  'User 클래스에 직렬화 코드를 구현하면 되지 않을까?'

User 클래스는 엔터티로 정의되어있다. 즉 다른 객체와 다대다. 일대다의 관계를 맺을 수 있는데, User클래스를 직렬화 할 경우 자식 엔티티까지 직렬화 대상에 포함되어. 성능 이슈, 부수 효과가 발생할 확률이 높다.

따라서 직렬화 기능을 가진 세션 Dto를 만드는 것이 운영 및 유지보수에 용이하다. 

 

2️⃣어노테이션 기반으로 개선하기

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

controller에서 해당 코드는 세션값이 필요할 때마다 반복되는 코드로 개선이 필요하다고 볼 수 있다.

그래서 이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 @LoginUser 어노테이션을 생성한다.

 

LoginUser - 세션값을 받을 수 있는 어노테이션 생성

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

// 파라미터로 선언된 객체에서 어노테이션이 생성될 수 있음.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
// LoginUser라는 이름을 가진 어노테이션 생성
public @interface LoginUser {
}

LoginUserArgumentResolver - HandlerMethodArgumentResolver 인터페이스 구현 클래스

// import 생략
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    @Override
    // 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단
    // @LoginUser 어노테이션이 붙어 있고, 파라미터 클래스 타입이 SessionUser.class 인 경우 true 반환
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
        return isLoginUserAnnotation && isUserClass;
    }

    @Override
    // 파라미터에 전달할 객체 생성
    // 세션에서 객체를 가져옴
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}
🔹HandlerMethodArgumentResolver
- 조건에 맞는 경우 메소드가 있다면 지정한 값으로 해당 메소드의 파라미터를 인자값들에 주입한다.
주로 아래의 경우에 사용한다.
  • parameter로 받는 값이 여러 개가 존재하고(혹은 객체의 필드들이 여러 개가 존재), 그것을 처리하는 코드들의 중복이 발생할 때
  • Controller에 공통으로 입력되는 parameter들을 추가하거나 수정하는 등의 여러 공통적인 작업들을 한 번에 처리하고 싶을 때
    https://webcoding-start.tistory.com/59

WebConfig - LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    // HandlerMethodArgumentResolver는 항상 아래 메소드를 통해 추가
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

 

✔️IndexController 에서 최종적으로 반복되는 코드를 @LoginUser 로 개선하면 다음과 같다.

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;

    @GetMapping("/")
    // 로그인 성공 시 세션 정보를 획득
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());
        // 세션에 저장된 값이 있을 때만 model에 userName으로 등록
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }

 

3️⃣세션 저장소로 데이터베이스 사용하기

내장 톰캣 메모리에 저장되고 호출되는 구조는 애플리케이션을 재실행 할 때마다 항상 초기화가 되어 로그인이 유지되지 않는다는 것과. 2대 이상의 서버에서 서비스를 한다면 톰캣마다 세션 동기화 설정을 해야한다는 문제점이 있다.

따라서 MySQL과 같은 데이터베이스를 세션 저장소로 사용하는 방식을 택했다.

 

build.gradle - spring-session-jdbc 의존성 등록

compile('org.springframework.session:spring-session-jdbc')

application.properties - 세션 저장소를 jdbc로 선택

spring.session.store-type = jdbc