Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

채채

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

spring boot

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

HChaeEun 2022. 11. 13. 18:24

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

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

 

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