본문 바로가기

Backend

Springboot (5) 스프링 시큐리티

1. 스프링 시큐리티

스프링 시큐리티를 사용하여 인증(Authentication), 인가(Authorization)을 손쉽게 구현할 수 있는 스프링 기반 애플리케이션의 보안 프레임워크이다.

 

A. OAuth2 스프링부트 버전 별 특징

1.5: application.properties에 client인증 정보 외 accessTokenUrl, userAuthorizationUrl, clientAuthenticationScheme를 입력해야 한다.

1.5: spring-security-oauth2-autoconfigure 라이브러리를 사용하여 호환되도록 설정할 수 있다.

2.0: application.properties에 client인증 정보(clientId, clientSecret)만 입력하면 된다. client 인증 정보 외 항목은 CommonOAuth2Provider라는 enum으로 제공된다. 구글, 깃허브, 페이스북, 옥타 지원.

 

2. 소셜 로그인 서비스 등록

소셜 로그인 기능을 활용하면 계정 보안 관련된 기능 구현 없이 사용자를 관리할 수 있다.

 

A. 구글 서비스 등록

https://console.cloud.google.com 에서 프로젝트 생성 후 아래 항목을 세팅한다.

i) API 및 서비스> OAuth 동의 화면 > 외부

아래 항목 선택 후 저장

- 애플리케이션 이름: 구글 로그인 시 사용자에게 노출 될 애플리케이션 이름.

- 지원 이메일: 사용자 동의 화면에서 노출 될 이메일 주소. 서비스 help 이메일을 입력한다.

- Google API의 범위: 구글에서 받아 쓸 정보 범위

ii) API 및 서비스 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID

웹 애플리케이션 선택 후 아래 항목 입력

- 이름: 앱의 표시 이름이 아닌 OAuth 클라이언트 ID의 이름.

- 승인 된 리디렉션 URI: 인증 성공 후 구글에서 리다이렉트 할 URL.

→ http://localhost:8080/login/oauth2/code/google을 등록한다(스프링 시큐리티에서 지원하는 URL. 별도의 컨트롤러를 구현이 필요 없음).

 

B. 네이버 서비스 등록

i) API 서비스 신청

https://developers.naver.com/apps/#/register?api=nvlogin 에 접속해서 다음 항목을 입력한다.

- 애플리케이션 이름: 로그인 시 사용자에게 표시되는 이름

- 사용 API: 사용을 원하는 API. 네아로(네이버 아이디로 로그인) / 회원이름, 이메일, 프로필 사진 체크

ii) 로그인 오픈 API 서비스 환경

환경추가 > PC 웹 선택 후 아래 항목 입력

- 서비스 URL: http://localhost:8080/

- 네이버아이디로로그인 Callback URL: 인증 성공 후 네이버에서 리다이렉트 할 URL.

→ http://localhost:8080/login/oauth2/code/naver를 등록한다(application-oauth.properties / naver.redirect-uri설정에 의해 자동 생성).

 

3. 애플리케이션 개발

A. 인증정보 세팅

[application-oauth.properties - google]

##google
#registration
spring.security.oauth2.client.registration.google.client-id=클라이언트ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트SECRET
spring.security.oauth2.client.registration.google.scope=profile,email

scope를 지정하지 않으면 scope에 openid 항목도 default로 설정되고 Open Id Provider로 인식된다. 이렇게 되면 Open Id Provider가 아닌(네이버, 카카오) 서비스용 OAuth2Service를 따로 만들어야 한다. 하나로 관리하기 위해 scope를 지정해준다.

 

[application-oauth.properties - naver]

##naver
#registration
spring.security.oauth2.client.registration.naver.client-id=클라이언트ID
spring.security.oauth2.client.registration.naver.client-secret=클라이언트SECRET
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope.=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver

#provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response

user-name-attribute=response: 네이버 응답값의 최상위 필드를 response로 설정한다.

네이버 API는 회원 조회 시 resultcode, message, response가 응답되며, response에 회원 정보가 담겨있다. 스프링 시큐리티에서는 하위 필드를 명시할 수 없으므로 response를 최상위 필드(user_name)로 지정한다.

 

 

[application-oauth.properties - google]

 

[application.properties]

...
spring.profiles.include=oauth

application-xxx.properties를 가져올 수 있다.

 

[.gitignore]

...
application-oauth.properties

중요한 인증정보(clientId, clientSecret)가 올라가지 않도록 .gitignore에 등록하여 제외시킨다.

 

B. 권한 관리용 Enum 생성

[Role.java]

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_ 접두어가 붙어야 한다.

 

C. 사용자 엔티티에 권한 항목 생성

[User.java]

@Entity
public class User extends BaseTimeEntity {
    ...
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;
    
    ...
}

@Enumerated(EnumType.STRING): JPA로 데이터베이스 저장 시 Enum 값의 저장 타입 지정. 기본적으로 int로 저장되기 때문에 데이터를 알아볼 수 있도록 문자열로 지정해준다.

 

D. OAuth2 설정

[build.gradle]

...
dependencies {
    ...
    compile('org.springframework.boot:spring-boot-starter-oauth2-client')
}

 

[/config/auth/SecurityConfig.java]

import com.wrallee.book.springboot.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;
import org.springframework.security.oauth2.client.userinfo.CustomUserTypesOAuth2UserService;

@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/**", "/js/**", "/h2-console/**").permitAll() // 빼먹으면 리다이렉트 되네??
                    .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: 스프링 시큐리티 설정 활성화

authorizeRequests(): URL 별 권한 관리 설정(시작)

antMatchers(): 권한 관리 대상 지정 옵션

anyRequest(): 설정된 값 이외 나머지 URL

userInfoEndpoint(): OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정(시작)

userService(): 로그인 성공 시 수행 할 UserService 구현체 지정

 

[/config/auth/CustomOAuth2UserService.java]

import com.wrallee.book.springboot.config.auth.dto.OAuthAttributes;
import com.wrallee.book.springboot.config.auth.dto.SessionUser;
import com.wrallee.book.springboot.domain.user.User;
import com.wrallee.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 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);
    }
}

OAuthAttributes, SessionUser → Dto 클래스

 

[/config/auth/dto/OAuthAttributes.java]

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

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

    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

 

E. 메소드 인자로 세션값 받기

세션 값을 불러오는 기능을 어노테이션 기반으로 만들어 쉽게 활용할 수 있다.

[/config/auth/LoginUser.java]

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)
public @interface LoginUser {
}

@Target: 어노테이션이 생성될 수 있는 위치 지정. PARAMETER로 지정하면 메소드 파라미터로 받을 수 있다.

@interface: 어노테이션 클래스임을 정의

 

[/config/auth/LoginUserArgumentResolver.java]

import com.wrallee.book.springboot.config.auth.dto.SessionUser;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpSession;

@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

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

supportsParameter(): 특정 파라미터를 지원하는지 판단하는 메서드. Annotation과 클래스 타입을 체크하도록 구현했다.

resolveArgument(): 파라미터에 객체를 전달.

 

[/config/WebConfig.java]

import com.wrallee.book.springboot.config.auth.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

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

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

LoginUserArgumentResolver를 등록해준다.

 

아래와 같이 @LoginUser 형태로 사용하면 된다.

...
    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());
        if (user != null) {
            model.addAttribute("sessionUserName", user.getName());
        }
        return "index";
    }
...

 

F. 세션 저장소로 데이터베이스 사용

[build.gradle]

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

 

[application.properties]

...
spring.session.store-type=jdbc

 

이후 h2-console에 접속하여 SPRING_SESSION, SPRING_SESSION_ATTRIBUTE 테이블을 확인한다.

 

4. 스프링 시큐리티 테스트

스프링 시큐리티 테스트를 위해서는 properties 설정과 테스트 라이브러리가 필요하다.

 

A. 가짜 설정값 생성

테스트 환경에서는 application.properties만 읽기 때문에 스프링 시큐리티 인증정보를 불러올 수 없다. 따라서 No qualifying bean of type 'xxx.config.auth.CustomOAuth2UserService' 라는 메시지가 표시된다.

src/test/resources에 아래 가짜 설정값을 생성해준다.

spring.jpa.show_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect
spring.h2.console.enabled=true
spring.session.store-type=jdbc

# Test OAuth
spring.security.oauth2.client.registration.google.client-id=test
spring.security.oauth2.client.registration.google.client-secret=test
spring.security.oauth2.client.registration.google.scope=profile,email

 

B. MockMvc 세팅

테스트 시 사용자를 지정하지 않았기 때문에 302(리다이렉션) 응답이 온다. 임의로 인증된 사용자를 추가한다.

[build.gradle]

dependencies {
    ...
    testCompile('org.springframework.security:spring-security-test')
}

 

[PostsApiControllerTest.java]

...
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
...

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
    @LocalServerPort
    private int port;

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    ...

    @Before
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }

    ...

    @Test
    @WithMockUser(roles = "USER")
    public void postsFindById() throws Exception {
        // given
        Posts posts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long generatedId = posts.getId();

        String url = "http://localhost:" + port + "/api/v1/posts/" + generatedId;

        // when
        MvcResult result = mvc.perform(get(url)
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(new ObjectMapper().writeValueAsString(generatedId)))
                .andExpect(status().isOk())
                .andReturn();

        // then
        ObjectMapper mapper = new ObjectMapper();
        PostsResponseDto dto = mapper.readValue(result.getResponse().getContentAsString(), PostsResponseDto.class);

        assertThat(dto.getId()).isEqualTo(generatedId);
    }
    
    ...
    
}

@WithMockUser(roles = "USER"): ROLE_USER 권한을 가진 인증된 모의 사용자를 만들어서 사용한다.

setup(): 테스트 시작 전 MockMvc 인스턴스 생성.

mvc.perform(): 생성 된 MockMvc를 통해 API를 테스트.

mapper.readValue(...): 응답 값을 PostsResponseDto 객체로 변환

 

'Backend' 카테고리의 다른 글

Springboot (7) 배포 자동화 - 2  (0) 2020.05.28
Springboot (6) 배포 자동화 - 1  (0) 2020.05.27
iBatis 쿼리 가져오기  (0) 2020.05.07
Springboot (4) 웹 애플리케이션 - 2  (0) 2020.04.29
Springboot (3) 웹 애플리케이션 - 1  (0) 2020.04.29