스프링 게시판 만들어보기 4 - 스프링 시큐리티와 OAuth2.0 적용

HiroPark·2022년 9월 12일
0

Spring

목록 보기
6/11

우선, 구글 클라우드 플랫폼에서 발급받은 OAuth 클라이언트 ID, 클라이언트 보안 비밀, 그리고 scope를 src/main/resources/application-oauth.properties에 등록합니다

spring.security.oauth2.client.registration.google.client-id=클라이언트 ID

spring.security.oauth2.client.registration.google.client-secret=클라이언트 보안 비밀

spring.security.oauth2.client.registration.google.scope=profile,email

잠깐, “클라이언트 ID”란 무엇일까요?

용어정리

  • Client = mine : 우리가 만든 서비스 ⇒ 리소스 서버에 접속해서 정보를 가져가는 클라이언트
  • Resoucre Owner = user : 우리의 사용자
  • Resoucre Server = Their : 유저는 Their 서비스에 회원가입이 돼 있는 상태
  • Authorization Server : 인증과 관련된 처리를 전담하는 서버 (Resoucre Server와 합쳐서 부르기도 함)

결국, 우리가 만든 서비스(클라이언트)의 ID를 통해 구글(리소스 서버)에 접속하여 정보를 받아오는 것입니다.

application-xxx.properties로 properties의 이름을 만들었기에, xxx에 해당하는 oauth로 profile이 생성됩니다.

이를 application.properties에서spring.profiles.include=oauth 로 설정값을 가져옵니다.

클라이언트ID와 보안 비밀은 보안에 있어 중요한 정보들이기 때문에, application-oauth.properties 파일은 .gitignore에 추가해주어서 깃허브에 공유되지 않게 합니다.

사용자 정보에 대한 도메인인 User를 생성해줍니다.

@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) // Enum값을 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로 된 숫자가 저장됩니다.
    • 그러나 Role의 경우 숫자로 저장되면 데이터베이스로 확인할 때, 그 값이 무슨 코드를 의미하는지 알 수가 없습니다.
    • 따라서 string 형태로 저장될 수 있도록 선언해줍니다

다음으로 Enum클래스 Role을 생성합니다

@Getter
@RequiredArgsConstructor
public enum Role { / 사용자의 권한을 관리 /
GUEST("ROLEGUEST", "손님"), // 스프링 시큐리티에서는 권한코드에 항상 ROLE 이 앞에 있어야 함
USER("ROLE_USER", "일반 사용자");

private final String key;
private final String title;

}


@Getter
@RequiredArgsConstructor
public enum Role { /* 사용자의 권한을 관리 */
GUEST("ROLE_GUEST", "손님"), // 스프링 시큐리티에서는 권한코드에 항상 ROLE_ 이 앞에 있어야 함
USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;
}
  • 스프링 시큐리티에서는 권한 코드에 항상 ROLE_ 이 앞에 있어야 하기에 코드별 키 값을 ROLE_GUEST의 형식으로 지정합니다.

User의 CRUD를 담당하는 UserRepository도 생성합니다.

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    // 소셜로그인으로 반환되는 email을 통해 이미 있는 사용자인지, 처음 가입하는 사용자인지 판단하기 위한 메소드
}
  • JpaRepository<엔티티클래스, pk클래스> 를 상속받았기에 CRUD메서드가 자동으로 생성됩니다.

  • Optional 은 null혹은 null아닌 값을 저장할 수 있는 컨테이너 입니다. 제네릭을 통해 들어올 수 있는 객체의 타입을 User로 제한하고 있습니다.

    build.gradle에 스프링 시큐리티 의존성을 추가합니다.

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

다음으로, 시큐리티 관련 클래스를 담을 config.auth 패키지를 생성합니다.

SecurityConfig

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception{
        http
            .authorizeRequests()
                    .antMatchers("/","/css/**","/images/**","/js/**","/profile").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout()
                        .logoutSuccessUrl("/")
                .and()
                    .oauth2Login()
                        .userInfoEndpoint()
                            .userService(customOAuth2UserService);

    }
}
  • @EnableWebSecurity
    • 스프링 시큐리티 설정들을 활성화시켜줍니다
  • authorizeRequests
    • URL별 권한 관리 설정하는 옵션의 시작점입니다.
    • 이게 있어야 antMatchers 옵션을 사용할 수 있습니다
    • antMatchers
      • 권한 관리 대상을 지정합니다
      • URL 또는 HTTP메소드별 관리가 가능합니다
      • permitAll() : 전체 열람 권한
      • 등록,수정, 삭제등의 기능을 사용하는 “api/v1/**” 주소(PostsApiController)는 USER 권한에게만 열어줍니다.
    • 나머지 URL들은 anyRequest().authenticated() 를통해 로그인한 사용자들에게만 열어줍니다.
  • Oauth2Login() : oauth2Login에 대한 설정을 시작하겠다는 의미입니다.
    • 로그인 성공 이후 정보를 가져오는 설정을 .userInfoEndpoint() 로 시작합니다
    • 로그인 성공이후 후속 조치를 진행하는 UserService 인터페이스의 구현체는 customOAuth2UserService 입니다.
    • 리소스 서버인 구글에서 사용자 정보를 가져온 상태에서, 추가적인 진행을 해당클래스가 담당합니다.

CustomOAuth2UserService

@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<OAuth2UserRequest, OAuth2User> 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);
    }
}
  • OAuth2UserService(인터페이스) 를 구현해줍니다
  • loadUser
    • 인터페이스의 loadUser를 오버라이드 → loadUser : UserInfo Endpoint에서 최종 사용자의 속성을 가져온 후 OAuth2User를 반환합니다.

    • 로그인 인증이 실패되어도 동작하도록 로그인 실패 Exception을 throw합니다.

    • delegate는 “대리인”이라는 뜻입니다.

      • DefaultOAuth를 통해 UserInfo 엔드포인트에서 사용자 정보를 가져와야 합니다.
      • CustomOAuth2UserService.loadUser 의 동작을 대신해주는 대리인을 만듭니다.
    • String registrationId = userRequest.getClientRegistration().getRegistrationId();

      • 후에 네이버 로그인을 추가할때 네이버 로그인인지, 구글로그인인지 서비스를 구분해주기 위해 코드를 추가합니다
    • String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

      • userNameAttributeName은 OAuth2로그인 진행시, 키가 되는 필드값입니다.( PK와 같은 의미)
        • 구글은 이를 지원하지만, 네이버와 카카오는 기본 지원하지 않습니다
        • 마찬가지로 네이버 로그인을 추가할때 사용합니다
    • OAuthAttributes attributes = OAuthAttributes.*of*(registrationId, userNameAttributeName, oAuth2User.getAttributes());

      • 위에서 OAuth2UserService를 통해 가져온 데이터를 OAuthAttributes 클래스에 담습니다.
    • User user = saveOrUpdate(attributes);

      • 위의 attributes를 가지고 User를 저장하거나, 업데이트 해줍니다.
    • httpSession.setAttribute("user", new SessionUser(user));

      • SessionUser는 세션에 사용자 정보를 저장하기 위한 Dto 클래스입니다.
      • httpSession은 무엇일까요?
        • 세션은 방문자가 웹 서버에 접속해있는 상태를 의미합니다.
        • HttpSession 인터페이스는 하나 이상의 page request에서 유저를 특정하거나, 유저에 대한 정보를 저장하는 데 도움을 주는 인터페이스 입니다.
        • 서블릿 컨테이너는 이 인터페이스를 이용하여 HTTP 클라이언트와 서버 간의 세션을 만듭니다.(한명의 사용자에 해당)
        • 객체를 세션에 바인딩하여, 유저 정보가 여러개의 user connection 도중에 지속될 수 있게 합니다.
      • SetAttribute 메서드는 특정 “name” 을 사용하여 객체를 세션에 바인딩합니다.
        • 만약 같은 이름을 가진 객체가 있다면 교체됩니다.
    • 마지막으로, DefaultOAuth2User를 만들어 반환할 것입니다.
      - public DefaultOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes, String nameAttributeKey)
      - 객체를 생성하기 위하여 GrantedAutority를 상속한 컬렉션과, attributes Map과 , nameAttributeKey가 필요합니다.
      - role을 인자로 받은 SimpleGrantedAuthority를 ,Set객체 하나만 저장 가능한 싱글톤 컬렉션을 통해 첫번째 DefaultOAuth2User에 의 첫번째 인자로 넘깁니다.
      - 나머지 인자들은 기존에 만들어 두었던 속성들을 활용합니다

      조금 복잡해서 정리해보았습니다.

    1. loadUser 메서드의 목표는 OAuth2User를 반환하는 것입니다.
    2. 이를 위해 delegate(대리인)을 설정하고, 대리인을 통하여 userRequest에 맞는 OAuth2User를 불러옵니다
    3. userRequest에서 가져온 registrationId(현재 로그인 진행중인 서비스가 구글인지, 네이버인지 구분), userNameAttirbuteName(로그인 진행시의 키가 되는 필드값) , 그리고 OAuth2User에서 불러온 유저에 대한 Map 속성을 가지고 OAuthAttributes를 생성합니다(이는 제가 생성한 Dto입니다)
    4. 3의 attributes를 통해 User 엔티티를 저장 혹은 업뎃 해준 이후, 세션에 바인딩도 해줍니다.
    5. DefaultOAuth2User를 만들어 반환합니다.

왜 httpSession에 사용자 정보를 바인딩할때 User 클래스를 만들지 않고 SessionUser 이라는 dto를 만들어서 쓸까요??

SessionUser

@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();
    }
}
  • User 클래스를 그대로 사용한다면 org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.Object] to type [byte[]] for value 'springboot.domain.user.User@58939314'; nested exception is org.springframework.core.serializer.support.SerializationFailedException: Failed to serialize object using DefaultSerializer; nested exception is java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [springboot.domain.user.User] 이런 에러가 뜹니다.
    • 객체를 DefaultSerializer를 통해 직렬화하는데 실패했다고 합니다.
    • User클래스에 직렬화를 구현하지 않았기 때문에 해당 에러를 보게됩니다.
    • 그렇다고 User 클래스에 직렬화를 넣자니, User 엔티티의 자식 엔티티 까지 직렬화 대상에 포함이 되기에 성능 및 부수 효과에 있어 문제가 발생합니다
    • 따라서 직렬화 기능을 가진 세션 dto를 추가로 하나 만드는 것입니다.

별다른 설정이 없다면 애플리케이션이 재시작 되면 로그인이 풀립니다.

세션이 WAS의 메모리에 저장되고 호출되어서, 내장 톰캣이 재시작할때마다 세션 정보가 날아가기 때문입니다.

이러한 일을 방지하기 위해 MySql을 세션 저장소로 사용합니다.

  • implementation('org.springframework.session:spring-session-jdbc') build.gradle에 의존성을 추가해줍니다.
  • spring.session.store-type =jdbc
    • application.properties에서 세션 저장소를 jdbc로 사용하도록 합니다.

    • JPA를 통하여 Mysql에 세션 테이블이 자동 생성됩니다.

테스트

마지막으로, 테스트를 스프링 시큐리티에 맞게 수정해보겠습니다.

  1. 테스트 환경을 위한 application.properties 생성

    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.datasource.url=jdbc:mysql://localhost:3306/board?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    spring.datasource.username=root
    spring.datasource.password=root
    spring.profiles.include=oauth
    spring.session.store-type =jdbc
    spring.session.jdbc.initialize-schema = always
    spring.jpa.show-sql=true
    spring.jpa.database=mysql
    spring.jpa.hibernate.ddl-auto=update
    spring.jpa.generate-ddl=false
    
    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=test

    테스트를 실행할 시, 별도의 application.properties가 없다면 main의 것을 가져오지만, application-oauth.properties까지는 가져오지 않기에, 테스트 환경을 위한 application.properties를 따로 생성합니다.

    • client-id 등은 임의의 값을 넣어줍니다.
  2. 임의로 인증된 사용자를 추가합니다.

    build.gradle에 스프링 시큐리티 테스트를 위한 도구를 지원해주는 testImplementation('org.springframework.boot:spring-boot-starter-test') 를 추가합니다.

    PostsApiControllerTest의 테스트 메소드들에 모의 사용자를 추가해줍니다

    @Test
    @WithMockUser(roles="USER")
    public void posts_등록() throws Exception { ...
    • @WithMockUser는 MockMvc에서만 작동합니다. 따라서, 매번 테스트 시작전에 MockMvc 인스턴스를 생성하여, 이를 통해 API를 테스트 해줍니다.
    @Before
        public void setup() {
            mvc = MockMvcBuilders
                    .webAppContextSetup(context)
                    .apply(springSecurity())
                    .apply(sharedHttpSession())
                    .build();
        }
    
    .
    .
    .
    .
    @Test
        @WithMockUser(roles="USER") // MockMVC에서만 작동
        public void posts_등록() throws Exception {
    .
    .
    
    mvc.perform(post(url)
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                            .content(new ObjectMapper().writeValueAsString(requestDto))) // 문자열 JSON으로 변환
                                    .andExpect(status().isOk());
    .
    .
    1. WebMvcTest가 Service를 스캔하지 못하는 것과, @EnableJpaAudition으로 인한 문제를 해결합니다
    • @WebMvcTest는 CustomOAuth2UserService를 스캔하지 않습니다(@Repository, @Service, @Component 를 스캔하지 않음)
    • SecurityConfig는 읽지만(@EnableWebSecurity에 @Configuration 설정이 들어있습니다)
    • , 이에 필요한 CustomOAuth2UserService를 읽지 못하기에 , 이를 스캔대상에서 제거해줍니다.
    @WebMvcTest(controllers = HelloController.class,
    excludeFilters = {
            @ComponentScan.Filter(type= FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
    })

    마찬가지로 @WithMockUser로 인증된 사용자를 만들어줍니다

    여전히 에러가 발생합니다.

    java.lang.illegalargumentexception at least one jpa metamodel must be present

    @EnableJpaAuditing는 엔티티들의 생성 및 수정 시간을 자동으로 관리해줍니다.

    이를 Application.java 클래스에 애노테이션으로 등록해두면 모든 테스트들이 항상 JPA관련 Bean을 필요로 하게됩니다.

    즉, @EnableJpaAuditing 을 사용하기 위해 최소 하나의 @Entity 클래스가 필요하게 됩니다

    WebMvcTest에는 이것이 없습니다,

    따라서 이를 Application.java에서 분리해 config 패키지에 별도로 생성합니다.

    • config/JpaConfig.java
    @Configuration
    @EnableJpaAuditing
    public class JpaConfig {
    }
    
profile
https://de-vlog.tistory.com/ 이사중입니다

0개의 댓글