코딩하는 문과생

[Spring Boot] Spring Security 본문

웹 프로그래밍/Spring Boot

[Spring Boot] Spring Security

코딩하는 문과생 2020. 12. 6. 16:42

※ 해당 글은 튜토리얼이 아닌 헷갈리거나 중요한 개념 위주로 정리한 글입니다. 

 

[스프링 시큐리티 흐름]

: 스프링 시큐리티는 단순히 코드 작성이 아니라 흐름이 정말 중요한 것 같다. 흐름을 짚고 넘어가자.

스프링 시큐리티 흐름

 

[Google Login]

구글 로그인을 위해서는 구글 클라우드 플랫폼에서 동의화면과 클라이언트ID를 생성해야 한다.

  • OAuth동의화면: 구글로그인 시 사용자에게 필요한 정보에 대해 동의를 요청하는 화면이다. 
  • 클라이언트 ID: 구글 로그인, 동의화면 관리 및 어플 개발시 어플에서 호출할 클라이언트 ID와 secret key를 생성한다. 이를 코드에 삽입해 사용하면 어플에서 구글로그인 호출이 가능하다.

* 승인된 리다이렉트 uri: 어플에서 파라미터로 인증 정보를 주었을 때, 인증에 성공하면 구글이 다이렉트할 url이다.

스프링 부트2버전은 기본적으로 도메인/login/oauth2/code/소셜서비스코드 로 리다이렉트를 지원한다.

 

- application-oauth.properties

  • 클라이언트 ID와 secret key는 application-oauth.properties에 작성 후 gitignore에 등록한다. 민감한 정보이기에 깃헙에는 따로 push하지 않는다.
  • 스프링 부트는 properties의 이름을 application-oauth.properties로 설정하고 이를 profile=oauth로 호출할 수 있다. 따라서 application.properties에 아래와 같은 코드를 추가하면, 스프링 부트가 이를 application-oauth.properties로 인식해 구글연동 시 필요한 민감정보를 불러올 수 있다.
# 스프링 시큐리티 : application-oauth.properties를 포함시키겠다.
spring.profiles.include=oauth

 

- 권한코드(ROLE)

: 권한코드에 항상 ROLE_가 있어야만 한다. 이렇게 해야 권한 호출이 가능하다.

package com.sijune.project.springboot.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;
}

 

-NPE처리를 위한 Optional<T>

: Option<T>를 이용해 NullPointException시 에러를 발생시키지 않을 수 있다.

public interface UserRepository extends JpaRepository<User, Long> { //Entity Id
    Optional<User> findByEmail(String email); //이메일을 통해 이미가입된 사용자인지 판단
    //NPE를 안전하게 받을 수 있다.
}

[파일 구성]

: 스프링 시큐리티 파일 구성은 아래와 같고, 중요한 파일 몇가지만 살펴보겠다.

 

- SecurityConfig

: 컨트롤러 역할을 한다. 권한부여, 로그아웃, 로그인 처리를 담당한다.

@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);
                //userInfoEndpoint: 로그인 성공시 사용자 정보를 가져올 때 설정 담당
                //userService: 이후 후속초치 즉, 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시(ex. 가입, 정보수정, 세션 저장 등등)

    }
}

 

-CustomOAuth2UserService

: 로그인 이후 사후처리를 담당한다. 가령 세션 저장, 가입 등이 있다.

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override //userRequest에 따른 User반환
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2UserService delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest); //userRequest에 따른 OAuthUser를 가져온다.

        String registrationId = userRequest.getClientRegistration().getRegistrationId(); //서비스 구분코드
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // PK와 동일

        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
        //OAuth2User의 attribute를 담을 클래스
        //attribute내에 name, email, picture정보가 있다.

        User user = saveOrUpdate(attributes); //받아온 유저 정보 저장

        httpSession.setAttribute("user", new SessionUser(user)); //세션 저장

        return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())), attributes.getAttributes(), attributes.getNameAttributeKey());
        //반환 값으로 ROLE, 유저의 Attributes, PK반환
    }

    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

: Dto이다. id에 따라(구글 또는 네이버) 받아온 값을 OAuthAttributes객체에 저장해 리턴한다. 왜냐하면 소셜로그인마다 리턴되는 형식과 값이 다르기 때문에 이렇게 설정해주어야 한다.

@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){ //서비스 구분, PK, 유저 정보
        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();
    }


}

[어노테이션 기반으로 개선]

: 중복된 코드를 개선하기 위해 사용한다.

 

- 어노테이션 생성

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser { //@LoginUser 생성
}

 

- HandlerMethodArgumentResolver

: 조건에 맞는 경우 구현체가 지정한 값으로 값을 넘길 수 있다.

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

- WenConfig

: WebMvcConfigurer를 상속받아 설정을 추가한다.

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

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

-IndexController

: 파라미터에 어노테이션과 클래스를 체크한 후, 파라미터 객체를 생성한다.

    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc()); //Model: 객체 저장 가능, service에 받은 값은 화면에 전달
        //SessionUser user = (SessionUser) httpSession.getAttribute("user"); //로그인 성공 시 세션에 user를 저장해놓음
        if(user != null){
            model.addAttribute("userName", user.getName());
        }
        return "index"; //index.mustache 로 변환되어 view Resolver(이름과 객체를 맵핑한다)가 처리한다.
    }

[세션 관리]

세션을 관리하는 방법은 여러가지가 있다. 톰캣과 같은 WAS를 이용하거나, 엘라스틱 캐시 등이 있지만, 소규모 서비스에서는 db로 관리하는 것이 가장 저렴하면서 운용이 쉽다.

 

-의존성 추가

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

- application.properties에 추가

# 세션 저장
spring.session.store-type=jdbc

이후 내장WAS 실행 시 SPRING_SESSION 테이블이 생성되며 세션 관리가 가능하다.


[네이버 로그인]

: 네이버는 구글 로그인 등록과 일부 프로세스가 상이하다.

 

1. application-oauth.properties에 registration이외 provider도 작성해야한다. 

특히 아래와 같은 코드를 추가해야 로그인 url도 자동으로 등록된다.

2. 반환되는 값이 response내 name, email과 같은 값을 담아오므로, 이에 관해 꺼내는 과정도 필요하다.(OAuthAttributes)

spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
# 리다이렉트 uri 등록시 로그인 url도 자동으로 등록된다

[주의사항]

- test환경 내 properties파일

test 폴더에는 자동으로 삽입되는 properties는 application.properties가 끝이다. application-oauth.properties는 자동으로 삽입되지 않으므로, 이에 대한 설정이 필요하다.

* 이 때, 가짜 설정값(application.properties)내 spring.profiles.include=oauth이 있으면 안된다. 해당 설정은 운영버전에서만 설정이 가능한 부분이다.

 

-SpringBootTest환경과 MockMvc환경

임의로 사용자를 추가해야한다. 이 때 @WithMockUser(roles="USER")를 사용해야 하지만 Test 컨트롤러는 SpringBootTest기반으로 작성되어 있으므로 해당 어노테이션을 사용하기 위해서는 MockMvc 이라는 추가설정이 필요하다.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) //JPA까지 테스트할 수 있도록 해준다. WebMvcTest는 단지 컨트롤러 테스트할 때 사용
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate; //webEnvironment설정시 이용 가능하다. Rest Api를 호출 후 응답을 기다리는 동기 방식

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Autowired
    private PostsRepository postsRepository;

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

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    @WithMockUser(roles="USER") //모의 사용자 생성
    public void Posts_Insert() throws Exception {
        //given
        String title = "title";
        String content = "content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder().title(title).content(content).author("author").build();

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

        //when
        mvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(new ObjectMapper().writeValueAsString(requestDto))) //JSON변환
                .andExpect(status().isOk());

        //then
                List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

    @Test
    @WithMockUser(roles="USER")
    public void Posts_Update() throws Exception {
        //given
        //1. 데이터 저장
        Posts savedPosts = postsRepository.save(Posts.builder().title("title").content("content").author("author").build());

        //2. id값 얻고,
        Long updateId = savedPosts.getId();

        //3. 업데이트할 데이터 생성
        String expectedTitle = "title2";
        String expectedContent = "content2";

        //4. 업데이트 객체 생성
        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder().title(expectedTitle).content(expectedContent).build();

        //5. 테스트할 url 생성
        String url = "http://localhost:"+port+"/api/v1/posts/" + updateId;

        //6. 업데이트할 객체를 http객체안에 넣는다.
        HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);

        //when
        //7. 테스트 시작, url에 post방식으로 http객체를 던지고, 반환값으로 long을 얻는다.
        mvc.perform(put(url)
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andExpect(status().isOk());

        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }

}

 

-@WebMvcTest환경과 Service

: @WebMvcTest는 컨트롤러 환경에 해당되는 설정을 읽는다. 따라서 @Service나 @Component는 스캔 대상이 아니다. 따라서 스캔대상에서 SecurityConfig(SecurityConfig 내 service가 있으므로)를 스캔대상에서 제거한다. excludeFilter를 이용해 스캔대상에서 SpringSecurity를 제거한다.

@RunWith(SpringRunner.class) //Junit 프레임워크의 SpringRunner라는 클래스를 실행시켜라
@WebMvcTest(controllers = HelloController.class, excludeFilters = {@ComponentScan.Filter(type= FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)})
public class HelloControllerTest {...}

 

- @EnableJpaAuditing

: 이를 사용하기 위해서 @Entity클래스가 필요하지만 @WebMvcTest는 컨트롤러 측만 테스트하다보니 해당 클래스가 존재하지 않는다. 따라서, @SpringBootApplication과 분리가 필요하다.

  1. Application 파일 내 @EnableJpaAuditing를 제거하고,
  2. config폴더 내 JpaConfig파일을 생성해 @EnableJpaAuditing을 추가해준다.
@Configuration
@EnableJpaAuditing //Jpa Auditing 활성화
public class JpaConfig {
}

 

※ 해당 글은 "스프링 부트와 AWS로 혼자 구현하는 웹 서비스(이동욱 저)"를 참고해 작성하였습니다.