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

박현우·2021년 3월 25일
0

Spring

목록 보기
5/11
post-thumbnail

스프링 시큐리티란?

스프링 시큐리티는 막강한 인증과 인가 기능을 가진 프레임워크입니다. Spring에서 사용하는 보안 기능의 표준이라고 보면 됩니다.

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

소셜 로그인을 사용하지 않으면 로그인 보안, 비밀번호 찾기 및 변경 등 모두 구현해야 합니다.
그래서 스프링 부트에서는 OAuth2를 이용하여 소셜 로그인과 연동을 합니다.

OAuth2란?

인증을 위한 표준 프로토콜입니다.
구글, 페이스북, 카카오 등에서 제공하는 Authorization Server를 통해 회원 정보를 인증하고 Access Token을 발급받습니다.
그리고 발급받은 Access Token을 이용해 타사의 API 서비스를 이용할 수 있습니다.
출처


스프링 부트 1.5 vs 2.0

2.0 버전에 들어서며 연동 방법이 크게 바뀌었습니다.
하지만 spring-security-oauth2-autoconfigure 라이브러리를 사용하면 2.0에서도 1.5 버전에서 쓰던 설정을 유지할 수 있습니다.

여기서는 spring-security-oauth2-autoconfigure 라이브러리를 사용하겠습니다. 이유는 다음과 같습니다.

  • 스프링 팀에서 1.5에서는 신규 기능은 추가하지 않고 버그 수정만 하겠다고 했습니다.
  • 스프링 부트용 라이브러리가 출시 되었습니다.
    기존에 사용되던 방식은 확장 포인트가 적절하게 오픈되어 있지 않아 직접 상속하거나 오버라이딩 해야 하고 신규 라이브러리의 경우 확장 포인트를 고려해서 설계된 상태입니다.

1.5 방식에서는 설정에서 url 주소를 모두 명시해야 하지만, 2.0 방식에서는 client 인증 정보만 입력하면 됩니다.
그리고 CommonOAuth2Provider라는 enum이 2.0에 새로 추가되어 다른 소셜 로그인을 추가하려면 여기서 하면 됩니다.


구글 서비스 등록

구글 서비스에 신규 서비스를 생성해야 합니다. 여기서 발급된 인증 정보(clientId, clientSecret)를 통해 로그인 기능과 소셜 서비스 기능을 사용할 수 있습니다.

구글 클라우드 플랫폼 이동 후 새 프로젝트를 만듭니다.



동의 화면 구성이 끝났으면 OAuth 클라이언트 ID를 만들어야 합니다.

여기서 승인된 리디렉션 URI만 다음과 같이 작성

승인된 리디렉션 URI

  • 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL입니다.
  • 스프링 부트 2 버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드} 로 리다이렉트 URL을 지원하고 있습니다.
  • 사용자가 별도로 리다이렉트 URL을 지우너하는 Controller를 만들 필요가 없습니다. 시큐리티에서 이미 구현해 놓은 상태.
  • 현재는 개발단계이므로 http://localhost:8080/login/oauth2/code/google 로만 등록합니다.
  • AWS 서버에 배포하게 되면 localhost 외에 추가로 주소를 추가해야 합니다.


다음과 같이 클라이언트 정보와 인증 정보를 확인할 수 있습니다.


클라이언트 ID와 클라이언트 보안 비밀 코드를 프로젝트에서 설정하겠습니다.

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

scope=profile, email

  • 많은 예제에서는 이 scope를 별도로 등록하지 않고 있습니다.
  • 기본값이 openid,profile,email이기 때문입니다.
  • 강제로 profile,email를 등록한 이유는 openid라는 scope가 있으면 Open id Provider로 인식하기 때문입니다.
  • 이렇게 되면 Open id Provider인 서비스(구글)와 그렇지 않은 서비스(네이버/카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 합니다.
  • 하나의 OAuth2Service로 사용하기 위해 일부러 openid scope를 빼고 등록합니다.

스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있습니다. profile-xxx으로 호출하면 해당 properties의 설정들을 가져올 수 있는 것이죠.

예를들어 application.properties에서 application-oauth.properties를 포함하도록 해보겠습니다.

application.properties
spring.profiles.include=oauth


.gitignore 등록.

구글 로그인을 위한 클라이언트 ID와 비밀은 보안이 중요하므로 깃허브에 올라가면 절대 안됩니다. 따라서 .gitignore에 보안요소가 들어있는 application-oauth.properties파일을 등록하겠습니다.

.gitignore에 파일을 등록해도 커밋 목록에 노출될시

git rm -r --cached .
git add .
git commit -m "fixed untracked files"

git의 캐시 문제이므로 캐시를 전부 지우고 add를 하면 된다고 합니다.


구글 로그인 연동하기

구글의 로그인 인증정보를 발급 받았습니다. 먼저 사용자 정보를 담당할 도메인인 User 클래스를 구현하겠습니다.

domain/user/User

  • @Enumerated(EnumType.STRING)

    • JPA로 DB로 저장할 때 Enum값을 어떤 형태로 저장할지를 결정합니다.
    • 기본적으로는 int가 저장됩니다.
    • 숫자로 지정되면 DB로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수가 없습니다.
    • 그래서 문자열로 저장될 수 있도록 선언합니다.

각 사용자의 권한을 관리할 Enum 클래스 Role을 생성합니다

domain/user/Role


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_이 앞에 있어야 합니다.


마지막으로 User의 CRUD를 책임질 UserRepository를 생성합니다.

domain/user/UserRepository

  • Optional findByEmail(String email);
    • 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드입니다.

User 엔티티 관련 코드를 전부 작성했습니다. 이제 시큐리티 설정을 하겠습니다. 먼저 build.gradle에 스프링 시큐리티 관련 의존성을 추가합니다.


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

  • 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성입니다.
  • spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해줍니다.

의존성 추가가 끝났으면, OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성해야합니다.
domain과 같은 위치에 config.auth 패키지를 생성합니다.

시큐리티 관련 클래스는 모두 이곳에 담습니다.
안에 SecurityConfig 클래스를 생성하고 코드를 작성합니다. 코드는 너무 길어 책을 참조합니다.


다음은 CustomOAuth2UserService 클래스를 생성합니다.

이 클래스는 구글 로그인 이후 가져온 사용자의 정보(email, name, picture 등)들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원합니다.
코드생략
saveOrUpdate 메소드인 이유는 구글 사용자 정보가 업데이트 되었을 때를 대비하여 update기능도 구현된 것입니다.


OAuthAttributes 클래스를 생성하겠습니다. 이것은 Dto의 역할을 하기 때문에 config.auth.dto 패키지를 만들어 그 패키지 안에 생성합니다.

여기서 User 클래스를 쓰지 않고 새로 만들어서 사용합니다!

SessionUser 클래스도 패키지 안에 만들어 줍니다.

SessionUser 클래스는 인증된 사용자 정보만 필요합니다. 그러므로 name, email, picture만 있으면 됩니다.


User 클래스를 사용하지 않는 이유

만약 User 클래스를 그대로 사용한다면 다음과 같은 에러가 발생합니다.

	Failed to convert from type [java.lang.Object] to

   type[byte[]] for value 'com.qweadzs.domain.user.User@2f2j43'

이는 세션에 저장하기 위해 User 클래스를 세션에 저장하려고 하니, User 클래스에 직렬화를 구현하지 않았다는 의미입니다.
User 클래스는 엔티티입니다. 만일 여기에 직렬화 코드를 구현하면 이와 관계있는 자식 엔티티들까지 영향이 미치기 때문에 직렬화 기능을 가진 세션 Dto를 만드는게 좋습니다.


스프링 시큐리티가 잘 적용되는지 로그인 버튼을 추가해 보겠습니다.
1. {{#userName}}

  • 머스테치는 다른 언어와 같은 if문을 제공하지 않습니다.
  • T/F 여부만 판단하기에 머스테치는 항상 최종값을 받아야합니다.

a href="/logout" class="btn btn-info active" role="button">Logout

  1. a href="/logout"
  • 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL입니다.
  • 기본적으로 제공되기 때문에 직접 컨트롤러를 만들 필요 없습니다.
  • SecurityConfig에서 URL을 변경할 수 있습니다.
  1. {{^userName}}
  • 머스테치에서 해당 값이 존재하지 않는 경우에 ^를 사용합니다.
  • 여기서는 userName이 없으면 로그인 버튼을 노출시킵니다.
  1. a href="/oauth2/authorization/google"
  • 이것 역시 시큐리티에서 기본으로 제공하는 로그인 URL입니다.

index.mustache에서 userName을 사용할 수 있게 IndexController에서 userName을 model에 저장하는 코드를 추가합니다.

@GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        SessionUser user = (SessionUser) httpSession.getAttribute("user"); // 1.
        if (user != null) { // 2.
            model.addAttribute("username", user.getName());
        }
        return "index";
    }
  1. (SessionUser) httpSession.getAttribute("user")
    • 앞서 저장된 CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했습니다.
    • 즉, 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있습니다.
  2. if(user != null)
    • 세션에 저장된 값이 있을 때만 model에 userName으로 등록합니다.

이제 테스트를 해보겠습니다.


로그인을 클릭하면 다음과 같이 로그인 과정이 진행됩니다.

허나, 로그인을 하고 글을 쓰려고하면
다음과 같은 에러가 발생하는데, 이것은 로그인된 사용자의 권한이 Guest라서 그렇습니다.

H2에서 권한을 user로 변경후 다시 글을 써보면


위와 같이 글 등록이 됩니다.


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

같은 코드가 반복되는 부분이 많아지면 나중에 유지보수도 힘들고 가독성이 떨어져 프로그램 효율이 떨어집니다.

어노테이션으로 앞서 만든 코드인 IndexController에서 세션 값을 가져오는 부분을 개선하겠습니다.

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

이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 변경하겠습니다.

config/auth/LoginUser

@Target(ElementType.PARAMETER) // 1.
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser { // 2.
}
  1. @Target(ElementType.PARAMETER)
    • 이 어노테이션이 생성될 수 있는 위치를 지정합니다.
    • 파라미터로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용할 수 있습니다.
    • 이 외에도 클래스 선언문에 쓸 수 있는 TYPE 등이 있습니다.
  2. @interface
    • 이 파일을 어노테이션 클래스로 지정합니다.
    • LoginUser라는 이름을 가진 어노테이션이 생성되었다고 보면 됩니다.

같은 위치에 LoginUserArgumentResolver 생성. HandlerMethodArgumentResolver 인터페이스를 구현한 구현체입니다(조건에 맞는 메소드가 있으면 구현체가 지정한 값으로 해당 메소드에게 파라미터로 넘김).

config/auth/LoginUserArgumentResolver

@RequiredArgsConstructor
@Component
public boolean supportsParameter(MethodParameter parameter) { // 1.
        boolean isLoginUserAnnotation 
        = parameter.getParameterAnnotation(LoginUser.class) != null;
        ...
        } 
public Object resolveArgument(MethodParameter parameter, ... // 2.
  1. supportsParameter()
    • 컨트롤러 메소드의 특정 파라미터를 지원하는지 판단합니다.
    • 여기서는 파라미터에 @LoginUser 어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class 일 경우 True를 반환합니다.
  2. resolveArgument()
    • 파라미터에 전달할 객체를 생성합니다.
    • 여기서는 세션에서 객체를 가져옵니다.

이렇게 파라미터의 타입과 어노테이션이 있으면 컨트롤러 메소드를 쓸 수 있는지 판단하는 메소드와, 파라미터에 객체를 전달하는 메소드를 구현했으니
스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가하겠습니다.

config/WebConfig

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

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

HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가해야 합니다. 다른 HandlerMethodArgumentResolver가 필요하다면 같은 방식으로 추가해 주면 됩니다.

IndexController에서 반복되는 부분들을 모두 @LoginUser로 개선하겠습니다.

IndexController

public String index(Model model, @LoginUser SessionUser user) { // 1.
        model.addAttribute("posts", postsService.findAllDesc());
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }
  1. @LoginUser SessionUser user
    • 기존에 (User)httpSession.getAttribute("user")로 가져오던 세션 정보 값이 개선되었습니다.
    • 이제는 어느 컨트롤러든지 @LoginUser만 사용하면 세션 정보를 가져올 수 있게 되었습니다.

세션 저장소로 DB 사용하기.

현재 우리의 세션은 H2라는 톰캣 내장 메모리에 저장되고 세션은 실행되는 WAS의 메모리에서 저장되고 호출됩니다. 즉, 배포할 때마다 톰캣이 재시작되고 로그인이 풀리게 되는 것이죠.

그리고 2대 이상의 서버에서 서비스한다면, 톰캣마다 세션 동기화를 해야합니다.

실제 현업에서는 세션 저장소에 대해 다음 3가지 중 한 가지를 택한다고 합니다.

  1. 톰캣 세션을 사용한다.
    • 일반적으로 별다른 설정을 하지 않을 때 기본적으로 선택되는 방식입니다.
    • 이렇게 될 경우 톰캣(WAS)에 세션이 저장되기 때문에 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요합니다.
  1. MySQL과 같은 DB를 세션 저장소로 사용한다.
    • 여러 WAS간의 공용 세션을 사용할 수 있는 가장 쉬운 방법입니다.
    • 많은 설정이 필요 없지만, 결국 로그인 요청마다 DB IO가 발생하여 성능상 이슈가 발생할 수 있습니다.
    • 보통 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용합니다.
  1. Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용한다.
    • B2C 서비스에서 가장 많이 사용하는 방식입니다.
    • 실제 서비스로 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요합니다.

저희는 두번째 방식을 사용합니다. 설정이 간단하고 사용자가 많은 서비스가 아니기 때문입니다.

이후 AWS에서 이 서비스를 배포하고 운영할 때를 생각하면 메모리 DB를 사용하기 부담스럽습니다(나중에 돈내야 됨). 서비스가 커진다면 변경을 고려해야 합니다.


spring-session-jdbc 등록

  1. 먼저 build.gradle에 의존성을 등록합니다.
    compile('org.springframework.session:spring-session-jdbc')

  2. application.properties에서 세션 저장소를 jdbc로 변경합니다.
    spring.session.store=type=jdbc

이후 h2에 접속하면 이런 테이블 2개가 생성됩니다.
JPA로 인해 세션 테이블이 자동 생성 되었기 때문입니다.
지금은 스프링을 재시작하면 똑같이 세션이 풀리지만, 이후 AWS로 배포하면 AWS의 DB 서비스중 RDS(Relational Database Service)를 사용할 수 있어 세션이 풀리지 않습니다.


네이버로 로그인하기

  1. 네이버 오픈 API로 이동합니다.
  2. 사용 API에 네이버 아이디로 로그인, 회원이름, 이메일, 프로필 사진 체크
  3. URL에 http://localhost:8080/, callback url에 http://localhost:8080/login/oauth2/code/naver 작성.
  4. 해당 value들을 application-oauth.properties에 등록.
    네이버는 스프링 스큐리티를 지원하지 않기 때문에 그동안 CommonOAuth2Provider에서 해주던 값들도 전부 수동 입력합니다.

spring.security.oauth2.client.provider.naver.user-name-attribute=response

  • 기준이 되는 user_name의 이름을 네이버에서는 response로 해야 합니다.
  • 이유는 네이버의 회원 조회 시 반환되는 JSON 형태 때문입니다.

스프링 스큐리티에선 하위 필드를 명시할 수 없습니다. 최상위 필드들만 user_name으로 지정 가능합니다. 하지만 네이버의 응답값 최상위 필드는 resultCode, message, response 입니다.

때문에, 스프링 시큐리티에서 인식 가능한 필드는 위 3개 중에 골라야 합니다. 본문에서 담고 있는 response를 user_name으로 지정하고 이후 자바코드로 response의 id를 user_name으로 지정하겠습니다.


스프링 시큐리티 설정 등록

구글 로그인에서 코드가 확장성 있게 작성되었으므로 네이버는 쉽게 등록할 수 있습니다. OAuthAttributes에 네이버인지 판단하는 코드네이버 생성자만 추가하면 됩니다.

그리고 머스테치에 로그인 버튼 추가

<a href="/oauth2/authorization/naver" 
class="btn btn-secondary active" role="button">Naver Login</a> // 1.
  1. /oauth2/authorization/naver
    • 네이버 로그인URL은 application-oauth.properties에 등록한 redirect-uri 값에 맞춰 자동으로 등록됩니다.
    • /oauth2/authorization/ 까지는 고정이고 마지막 Path만 소셜 로그인 코드를 사용합니다

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

기존에는 바로 API를 호출할 수 있었고 테스트 코드도 마찬가지였습니다.
하지만, 시큐리티 옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있습니다.

기존의 API테스트 코드들이 인증 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것 처럼 작동하도록 수정해야 합니다.

gradle 탭에서 Tasks -> verification -> test 로 전체 테스트를 수행합니다.


그럼 이렇게 롬복을 제외한 모든 테스트가 실패합니다. 저는 가짜 설정값을 등록해서 테스트 몇개가 통과합니다.

문제 1. CustomOAuth2UserService를 찾을 수 없음.

hello가 리턴된다에서 No qualifying bean ... 이라는 메시지가 보입니다.
이는 소셜 로그인 관련 설정값들이 없기 때문에 발생합니다.

src/main, src/test 환경 차이 때문에 application-oauth.properties에 설정값을 추가해도 위와 같은 문제를 겪습니다.
하지만, src/main/resources/application.properties가 없으면 main의 설정을 그대로 가져오기 때문에 테스트 코드를 수행해도 적용이 되는 것입니다. 가짜 설정값을 통해 해결.


문제 2. 302 FOUND


예상으로 200(정상), 결과는 302(리다이렉션 응답)입니다. 이것은 스프링 스큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동 시키기 때문입니다.
그래서 이런 API 요청은 임의로 인증된 사용자를 추가하여 테스트 하겠습니다.

스프링 스큐리티 테스트를 위한 도구를 지원하는 spring-security-test를 build.gradle에 추가합니다.

그 후 PostsApiControllerTest에 어노테이션으로 임의 사용자를 추가합니다.

@WithMockUser(roles = "USER") // 1.
    public  void Posts_등록된다() throws Exception{
    ...
    }
  1. @WithMockUser(roles = "USER")
    • 인증된 모의 사용자를 만들어서 사용합니다.
    • USER 권한을 가진 사용자가 API를 요청하는 것과 동일 효과입니다.

MockMve에서만 작동하기에 @SpringBootTest에서 MockMvc를 사용해야 합니다.

 @Before // 1.
    public void setup() { 
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }
    
    ....
    mvc.perform(post(url) // 2.
    
  1. @Before
    • 매번 테스트가 시작되기 전에 MockMvc 인스턴스를 생성합니다.
  2. mvc.perform(post(url))
    • 생성된 MockMvc를 통해 API를 테스트 합니다.
    • body 영역은 문자열로 표현하기 위해 ObjectMapper를 통해 문자열 JSON으로 변환합니다.


다시 테스트를 하면 Posts 테스트도 정상적으로 동작합니다.


문제 3. WebMvcTest에서 CustomOAuth2UserService를 찾을 수 없음.

@WebMveTest는 CustomOAuth2UserService를 스캔하지 않습니다. 즉, @Repository, @Service, @Component는 스캔 대상이 아닙니다. 그러니 SecurityConfig는 읽었지만, SecurityConfig를 생성하기 위해 필요한 CustomOAuth2UserService는 읽을 수 없어 앞에서와 같이 에러가 발생한 것입니다.

스캔대상에서 SecurityConfig 제거 후 @WithMockUser를 사용해 모의 사용자 생성.

추가 문제 JPA metamodel must not be empty!

@EnableJpaAuditing으로 인해 발생하는 에러입니다. @EnableJpaAuditing을 사용하기 위해 최소 하나의 @Entity 클래스가 필요합니다.
어플리케이션에서 @EnableJpaAuditing과 @SpringBootApplication 분리
모든 테스트 통과 완료입니다.

0개의 댓글