CheerUp_Cheers

스프링 부트 - (5) 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 본문

서적 공부/스프링부트 - [스프링부트와 AWS로 혼자 구현하는 웹서비스]

스프링 부트 - (5) 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기

meorimori 2020. 3. 24. 22:52

#스프링 시큐리티

- 막강한 인증

- 인가

 

5.1 스프링 시큐리티와 스프링 시큐리티 Oauth2 클라이언트

#OAuth 기능 목록

- 로그인 시 보안

- 비밀번호 찾기

- 비밀번호 변경

- 회원정보 변경

- 회원가입 시 이메일 혹은 전화번호 인증.

 

#스프링부트 2.0방식

다음 라이브리를 사용함으로써 가능.

spring-security-oauth2-autoconfigure

- 1.5에서 쓰던 방식을 그대로 사용 가능.

- url 주소 모두 명시 -> client 인증 정보만 입력 하면 가능.

- 직접 입력 -> enum으로 변경.

 

5.2 구글 서비스 등록

[1] https://console.cloud.google.com/  접속

 

Google Cloud Platform

하나의 계정으로 모든 Google 서비스를 Google Cloud Platform을 사용하려면 로그인하세요.

accounts.google.com

 

[2] 프로젝트 선택 후, 프로젝트 생성.

 

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

 

[4] 동의 화면 구성 > 외부, OAuth 클라이언트 ID 만들기

 

[5] application-oauth.properties 생성

위치 : src/main/resources/

기본값은 openid, profile, email이기 때문에, scope를 profile, email로 등록.

-> 하나의 OAuth2Service로 사용하기 위함( openid서비스를 제공하거나 제공하지 않는 사이트가 존재)

spring.security.oauth2.client.registration.google.client-id= 638451930553-7gbbg51ghkq4rcomvjfg6lp7ubslnii7.apps.googleusercontent.com
spring.security.oauth2.client.registration.google.client-secret=7tAcP4JzTJLxgtUDB20tPRbc
spring.security.oauth2.client.registration.google.scope=profile,email

 

[6] application.properties에 내용 추가.

spring.profiles.include=oauth

 

[7] .gitignore에 등록

중요한 정보들을 가리기 위함.

application-oauth.properties

5.3 구글 로그인 연동

[1] User 클래스 생성

위치 : domain/user/User

- @NoArgsConstructor : 파라미터 없는 기본 생성자 생성.

- @Entity : 객체와 테이블 매핑.

- @Builder : Builder를 자동으로 생성.

- @Enumerated(EnumType.STRING)

  JPA로 데이터를 저장할 때, 어떤 형태로 저장할지 선택.

  기본적으로 int로 된 숫자 저장.

소스보기

더보기
package com.jojoldu.book.springboot.domain.user;

import com.jojoldu.book.springboot.domain.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

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

 

[2] Role 이넘 생성.

위치 : domain/user/Role

각 사용자의 권한을 관리하는 Enum클래스.

게스트? 유저?

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

소스보기

더보기
package com.jojoldu.book.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;

}

 

[3] UserRepository 생성

위치 : domain/user/UserRepository.

- findByEmail() : 소셜 로그인으로 반환되는 값 중, email을 통해 이미 생성된 사용자인지 판별.

더보기
package com.jojoldu.book.springboot.domain.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
	Optional<User> findByEmail(String email);
}

 

[4] 스프링 시큐리티 설정

위치 : build.gradle

스프링 시큐리티 관련 의존성 추가.

- spring-boot-starter-oauth2-client

  클라이언트 입장에서 소셜 기능 구현시 필요한 의존성.

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

 

[5] config.auth 패키지 생성

[6] CustomOAuth2UserService 클래스 생성 > SecurityConfig 클래스 생성

[7] OAuthAttrivutes 클래스 생성.

위치 : config/auth/dto/OAuthAttrivutes

- of() : OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에, 하나하나 반환.

- toEntity()

  User 엔티티 생성.

  처음 가입할 때, 기본권환을 GUEST로 줌.

소스보기

더보기
package com.jojoldu.book.springboot.config.auth.dto;

import com.jojoldu.book.springboot.domain.user.Role;
import com.jojoldu.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) {
        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();
    }



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

 

[8] SessionUser 클래스 생성

위치 : config/auth/dto/SessionUser

인증된 사용자 정보만 필요.

왜?) User를 그대로 직렬화안했어요?

  User 클래스는 엔티티이기 떄문에, 어떤 객체와 어떤 관계를 가질지 모름.

  @OneToMany, @ManyToMany...

  자식들까지 직렬화에 포함되니 성능이슈, 부수효과를 줄이기위함.

소스보기

더보기
package com.jojoldu.book.springboot.config.auth.dto;

import com.jojoldu.book.springboot.domain.user.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();
    }
}

 

[9] index.mustache 수정

- {{#userName}}

  머스테치는 true/false만 판단 -> 항상 최종값만 원함

  user가 있따면 userName을 노출 하도록 구성.

  여기서는 로그인이 되어서 아이디 노출 창!

- {{^userName}}

  userName이 없다면, 자동으로 보여줌.

  여기서는 로그인!

- a href="/logout"

  스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL -> 따로 컨트롤러 제작 불필요.

  SecurityConfig 클래스에서 URL을 변경할 순 있음.

소스보기

더보기
<div class="col-md-12">
    <div class="row">
        <div class="col-md-6">
            <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
            {{#userName}}
                Logged in as: <span id="user">{{userName}}</span>
                <a href="/logout" class="btn btn-info active" role="button">Logout</a>
            {{/userName}}
            {{^userName}}
                <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
            {{/userName}}
        </div>
    </div>
    <br>

 

[10] indexContorller 수정

index.mustache에서 userName을 사용 할수 있도록 변경.

- (SessiongUser) httpSession.getAttribute("user")

  CustomOAuth2UserService에서 로그인 성공시 세션에 SessionUser을 저장하도록 구성.

  즉, 로그인 성공시 값을 가져올 수 있음.

- if(user != null)

  세션에 저장된 값이 있느냐 없느냐..

소스보기

더보기
@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());

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

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

        return "index";
    }

}

 

[ 결과 확인 ]

5.4 어노테이션 기반으로 개선하기

목표 : 같은 코드가 반복되는 것을 피하기 위함.

  -> 메소드 인자로 세션값을 바로 받을수 있게 하자

예 : User user = (User) httpSession.getAtrribute("user");

[1] LoginUser 인터페이스 (어노테이션) 생성

위치 : cofing/auth/LoginUser

- @Targer

  이 어노테이션이 올수있는 위치를 지정.

  파라미터로 정의 했으니, 여기서는 메소드의 파라미터로 선언된 객체에서만.

- @interface

  이파일을 어노테이션 클래스로 지정(생성)

package com.jojoldu.book.springboot.config.auth;

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

 

[2] LoginUserArgumentResolver

목표 : LoginUserArgumentResolver가 지정한 구현체의 값으로 해당 메소드 파라미터 보낼수 있음.

위치 : config/auth/LoginUserArgumentResolver

- supportsParameter()

  파라미터에 @LoginUser 어노테이션이 붙어있고 (&&)

  파라미터 클래스 타입이 SessionUser일 경우 true반환.

- resolveArgument()

  파라터에 전달한 객체를 생성(여기선 세션에서 객체를 가져옴)

 

소스보기

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

 

[3]WebConfig 생성

목표 : 스프링에서 인식할 수 있도록 WebMvcConfigure 추가.

HandlerMethodArguemntResolver는 항상 WebMvcConfigure의 addArguementResovlers()를 통해 추가.

 

소스보기

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

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

 

[4] 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());

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

        return "index";
    }

    
}

 

 

5.5 세션 저장소로 데이터베이스 사용하기

현재 : 로그인 정보를 내장 톰캣의 메모리에 저장, 배포할 때마다 톰캣이 재시작(초기화)

개선 : MySQL와 같은 데이터베이스 사용

 

[1] build.gradle 의존성 추가

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

 

[2] application.properties 설정 추가

세션 저장소를 jdbc로 선택하도록 추가.

spring.session.store-type=jdbc

JPA로 세션테이블이 자동으로 생성 됨.

 

5.6 네이버 로그인

[1] https://developers.naver.com/apps/#/register?api=nvlogin 접속

 

[2] 서식 등록

 

[3] 발급받은 ID와 PASS application-oauth.properties에 등록.

일일이 수동으로 입력 -> 네이버는 Spring Security를 지원 안함.

스프링 시큐리트에서는 하위필드를 명시 불가.

->response의 하위 필드에 이름, 이메일 등등 존재.

- user_name_attribute=response

  기준이 되는 user_name의 이름을 네이버에서는 reponse로해야함.

  회원 조회시 반환되는 값이 JSON이기 떄문,

 

소스보기

더보기
# registration
spring.security.oauth2.client.registration.naver.client-id=WIxfOSY3tBgKf8tiIl6Q
spring.security.oauth2.client.registration.naver.client-secret=RBWPulSdqj
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

 

[4] OAuthAttributes 추가

위치 : config/auth/dto/OAuthAttributes

네이버인지 판단하는 코드와 네이버 생성자만 추가.

 

소스보기

더보기
  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 ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .picture((String) attributes.get("profileImage"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

 

[5] index.mustache에 네이버 버튼 추가.

- /oauth2/authorization/naver

  /naver 전까지는 고정, 마지막만 소셜 로그인 코드를 사용.

  네이버 로그인 URL은 application-oauth.properties에 등록한 redirect-uri_template에 맞춰 등록.

 {{^userName}}
                <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
                <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
  {{/userName}}

 

5.7 기존 테스트에 시큐리티 적용하기

Gradle > Tasks > Verification > Test 

테스트하면, 다음과 같이 실패한 것을 볼수 있음.

# hello_will_be_return

No qualifying bean of type 'com.jojoldu.book.springboot.config.auth.CustomOAuth2UserService' 라는 오류.

이는, 소셜 로그인 관련 설정값들이 없기 때문에 발생.

!)application-oauth.properties에 설정했는데 왜 없는가??

- src/main환경과 src/test환경이 다름.

- 테스트 환경을 위한 가짜 설정값을 통해 실험 -> application.properties를 새로 만듬.

 

위치 : src/test/resoures/application.properties

 

# Post_등록된다

스프링 시큐리티 설정으로 인증되지 않은 사용자의 요청은 이동.

-> 임의로 인증된 사용자를 추가 -> API만 테스트.

[1] 테스트를 위한 spring-security-test를 build.gradle에 추가.

 testCompile("org.springframework.security:spring-security-test")

 

[2] PostApiControllerTest의 2개 메소드 추가.

위치 : src/test/web

- @WithMockUser(roles = "USER")

  인증된 모의(가짜) 사용자를 만들어 사용.

  이 어노테이션으로 권한을 가진 사용자가 API를 이용하는것과 같아짐.

- @Before

  매번 테스트가 시작되기전에, MockMvc 인스턴트 생성.

- @mvc.perform

  생성된 MockMvc를 통해 api를 테스트.

  본문 영역은 문자열로 표현하기위해 ObjectMapper를 통해 문자열 JSON으로 변환.

 

소스보기

더보기
package com.jojoldu.book.springboot.web;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.util.List;

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.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// For mockMvc

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @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_등록된다() 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)))
                .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_수정된다() throws Exception {
        //given
        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long updateId = savedPosts.getId();
        String expectedTitle = "title2";
        String expectedContent = "content2";

        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

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

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

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

 

# @WebMvcTest에서 CustomOAuth2UserService를 찾을 수 없음.

첫번쨰와는 조금 다름 -> WebMvcTest를 사용 하기 때문.

@WebMvcTest는 CustomOAUth2UserService를 스캔하지않음.

@Service, @Component는 스캔 대상이 아님.

SecurityConfig를 생성하기 위한 CustomOAuth2UserService를 읽을수 없기 떄문에, 발생.

 

[1] 스캔대상에서 SecurityConfig를 제거.

@WebMvcTest(controllers = HelloController.class,
        excludeFilters = {
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
        }
)

 

[2] @WithMockUser를 이용한 가짜 인증된 사용자 생성.

기존의 코드에 @WithMockUser 어노테이션을 선택.

 

소스보기

더보기
 @WithMockUser(roles="USER")
    @Test
    public void hello가_리턴된다() throws Exception {
        String hello = "hello";

        mvc.perform(get("/hello"))
                .andExpect(status().isOk())
                .andExpect(content().string(hello));
    }

    @WithMockUser(roles="USER")
    @Test
    public void helloDto가_리턴된다() throws Exception {
        String name = "hello";
        int amount = 1000;

        mvc.perform(
                get("/hello/dto")
                        .param("name", name)
                        .param("amount", String.valueOf(amount)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name", is(name)))
                .andExpect(jsonPath("$.amount", is(amount)));
    }
}

 

[2-1] 추가 에러 발생

java.lang.IllegalArgumentException : At least One JPA

metamodeal must be present.

  1)@EnableJpaAuditing으로 인해 발생(= 최소 하나의 @Entity클래스 필요)

  -> @WebMvcTest니까 당연히 없음.

  2)@EnableJpaAuditing과 @SpringBootApplication이 함께 있어서 @WebMvcTest도 스캔.

  -> 둘을 분리.

같이 있음.

  3) Application의 @EnalbeJpaAuditing을 지우고, config/auth/JpaConfig을 추가.

package com.jojoldu.book.springboot.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaConfig {
}

 

[ 결과 확인 ]

테스트하는게 더 힘들다..