글 작성자: 자바니또

이번 포스팅은 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스'라는 책을 읽고 책의 내용을 정리한 것입니다.
책의 장점은 저자가 경험한 것과 생각을 글로 나마 빠르게 경험할 수 있다는 것입니다.
깊이 이해 하지 못하더라도 그러한 것이 있다라는 것을 안다는 것만으로도 충분히 가치있다고 생각합니다.
더 나아가서 직접 해본다면 더더욱 이득이겠지요.

개요

스프링 시큐리티는 막강한 인증(Authentication)과 인가(Authorization) 기능을 가진 프레임워크이다.
스프링은 인터셉터, 필터 기반의 보안 기능을 구현하는 것보다 스프링 시큐리티를 통해 구현하는 것을 적극 권장하고 있다.
이번 장에서는 스프링 시큐리티와 OAuth 2.0을 구현한 구글 로그인을 연동하여 로그인 기능을 추가하는 방법에 대해 알아보자.

목차

  • 구글 서비스 등록
  • 스프링 시큐리티 관련 의존성 추가
  • application-oauth등록
  • 권한 관리 enum 클래스 Role 생성
  • User 엔터티 클래스 생성
  • UserRepository 생성
  • 스프링 시큐리티 의존성 추가
  • SecurityConfig클래스 생성하기
  • CustomOAuth2UserService 클래스 생성하기
  • OAuthAttributes 생성하기
  • SessionUser 생성하기
  • 로그인 버튼 만들기

구글 서비스 등록

먼저 구글 서비스에 신규 서비스를 생성한다. 여기서 발급된 인증정보(clientId와 clientSecret)를 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있다. 구글 클라우드 플랫폼으로 이동하여 프로젝트를 생성하고 OAuth동의화면을 작성하여 사용자 인증정보를 발급받는다. 인증정보를 발급 받는 과정은 생략하도록 한다.

시큐리티 관련 의존성 추가

소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 다음의 의존성을 추가해야 한다.

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

위의 의존성을 build.gradle.에 추가해주면 spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.

application-oauth등록

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

스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리 할 수 있다. 여기서는 기본 설정파일인 application.properties에서 oauth 프로파일을 포함하도록 한다.

spring.profiles.include=oauth

주의할 점 소셜 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀은 보안이 중요한 정보들이므로 노출되지 않도록 한다.

권한 관리 enum 클래스 Role 생성

@Getter
@RequiredArgsConstructor
public enum Role {
    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;
}

스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 한다. 열거형 타입 클래스 이름을 리턴하는 name() 메서드를 기본으로 가지고 있는데, 예를 들어 Role.USER.name()은 "USER"를 리턴한다.

User 엔터티 클래스 생성

사용자 정보를 담당할 도메인인 User 클래스를 생성해보자.

@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
    //...

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    //...
}

필요한 컬럼들과 생성자, 메서드를 추가한다. 여기서 User클래스의 Enumerated(EnumType.STRING)의 역할은 다음과 같다.

  • JPA로 데이터베이스로 저장할 때 enum 값을 어떤 형태로 저장할지를 결정한다.
  • 기본적으로는 int로 된 숫자가 저장된다. 숫자로 저장되면 데이터베이스로 확인할 때 의미를 알 수 없다. 그래서 문자열로 저장될 수 있도록 선언한다.

UserRepository 생성

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

소셜 로그인으로 반환되는 값 중 이름이 email인 값이 인자의 값과 같은 값을 찾아내어 엔터티를 리턴한다. 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메서드이다.

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

build.gradle에 다음을 추가한다.

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

Oauth2는 스프링부트에서 공식지원하기 때문에 starter만 추가해주면 spring-security-oauth2-clientspring-security-oauth2-jose를 기본으로 관리해준다.

SecurityConfig클래스 생성하기

config.auth패키지를 생성하고 그 안에 다음과 같이 코드를 작성한다.

@RequiredArgsConstructor
@EnableWebSecurity    // (1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()    
            .headers().frameOption().disable()    // (2)
            .and()
                .authorizeRequests()    // (3)
                .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
                .antMatchers("/api/v1/**").hasRole(Role.USER.name()) //(4)
                anyRequest().authenticated()    // (5)
            .and()
                .logout()
                    .logoutSuccessUrl("/")    // (6)
            .and()
                .oauth2Login()    // (7)
                    .userInfoEndpoint()    //(8)
                        .userService(customOAuth2UserService);    //(9)
    }
}
  • (1) @EnableWebSecurity : Spring Security 설정들을 활성화시켜 준다.
  • (2) csrf().disable().headers().frameOptions().disable() : h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.
  • (3) authorizeRequests : URL별 권한 관리를 설정하는 옵션의 시작점이다. authorizeRequests가 선언되어야만 antMathcers 옵션을 사용할 수 있다.
  • (4) antMatchers : 권한 관리 대상을 지정하는 옵션이다. URL, HTTP메서드 별로 관리가 가능하다. "/"등 지정된 URL들은 permitAll()로 전체 열람 권한을 주고, "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 하였다.
  • (5) anyRequest : 설정된 값들 이외 나머지 URL들을 나타낸다. 여기서는 authenticated()를 추가하여 나머지 URL들을 모두 인증된 사용자들에게만 허용한다.
  • (6) logout().logoutSuccessUrl("/") : 로그아웃 기능에 대한 여러 설정의 진입점으로 로그아웃 성공 시 "/"주소로 이동한다.
  • (7) oauth2Login : OAuth2 로그인 기능에 대한 여러 설정의 진입점이다.
  • (8) userInfoEndPoint : OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다.
  • (9) userService : 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록한다. 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.

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 {

    // ******************  1. delegate  ******************
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

    // *********************  2. Load  *********************        
        String registrationId = userRequest.getClientRegistration().getRegistrationId();    // (1)
        String userNameAttributeName = userRequest.getClientRegistration()
                                                .getProviderDetails()
                                                .getUserInfoEndpoint()
                                                .getUserNameAttributeName(); // (2)

    // *********************  3. Push  *********************
        OAuthAttributes attributes = OAuthAttributes.of(registrationId
                                                    , userNameAttributeName
                                                    , oAuth2User.getAttributes());    // (3)

    // **************  4. Save or Update to DB **************
        User user = saveOrUpdate(attribute);

    // **************  5. Save in HttpSession **************
        httpSession.setAttribute("user", new SessionUser(user));    // (4)

    // **************  6. Return OAuth2User   **************
        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);
    }
}
  • (1) registrationId : 현재 로그인 진행 중인 서비스를 구분하는 코드이다. 구글의 경우 "google", 네이버의 경우 "naver"를 리턴한다.
  • (2) userNameAttributeName : OAuth2 로그인 진행 시 키가 되는 필드값이다. 구글의 경우 기본적으로 기본코드("sub")를 지원하지만, 네이버 카카오 등은 기본 지원하지 않는다. 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용된다.
  • (3) OAuthAttributes : OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담기 위해 만든 클래스이다. 이후 네이버 등 다른 소셜로그인도 이 클래스를 사용할 것이다.
  • (4) SessionUser : 세션에 정보를 저장하기 위한 Dto 클래스이다.

이 코드의 기능은 email을 통해 로그인한 사용자가 데이터베이스에 없으면 저장하고 있다면 정보를 최신화 시키는 코드이다. 코드의 흐름은 다음과 같다.

  1. Delegate
    : CustomOAuth2UserService는 로그인 성공 시 리소스 서버에서 사용자 정보를 가져온 상태에서 기능을 추가하고 싶어서 만든 클래스이다. 따라서 기본적인 OAuth2UserService기능은 DefaultOAuth2UserService에 위임한다.
  2. Load
    : 메서드 인자로 전달된 OAuth2UserRequest를 통해 필요한 정보들을 가져온다. 여기서는 어떤 서비스의 로그인을 진행하는지 알 수 있는 registrationId와 OAuth2 로그인 진행 시 primary key역할을 하는 userNameAttributeName값을 가져온다.
  3. Push
    : 로그인한 사용자의 정보들을 담을 클래스인 OAuthAttributes를 생성하여 데이터를 저장한다. OAuthAttributes는 registrationId에 따라 다른 객체를 리턴한다. 특히 인자로 OAuth2User의 Map형식의 속성값들을 담아둔다. 만약 로그인한 사용자의 정보가 이미 DB에 있다면 OAuthAttributes의 정보를 통해 DB의 사용자 정보를 최신화 하고, 없다면 OAuthAttributes객체를 Entity화하여 DB에 저장한다.
  4. DB Save or Update
    : 로그인을 시도하는 사용자 요청의 정보가 담긴 OAuth2Attributes에서 Email을 통해 사용자 정보가 있는지 확인하고 있다면 업데이트, 없다면 새로 생성한다.
  5. Save in HttpSession
    : Controller에서 Session을 통해 User의 인증된 정보에 접근할 수 있도록 저장한다. 이때 User를 SessionUser로 변경하여 HttpSession에 저장한다.
  6. Return OAuth2User
    : 1번과 마찬가지로 원하는 부가기능을 추가했다면 DefaultOAuth2User객체를 생성하여 리턴한다.

OAuthAttributes 생성하기

@Getter
public class OAuthAttributes {
    private Map<String, Object> attribute;
    //...
    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(){    //... }
}

OAuthAttributes는 Dto로 볼 수 있기 때문에 config.auth.dto에 생성한다.

SessionUser 생성하기

@Getter
public class SessionUser implements Serializable {
    //...
}

여기서 의문이 생길 수 있다. 왜 세션에 User 클래스를 직접 사용하지 않고 SessionUser를 만들어 사용해야 할까? 정답은 엔터티 클래스이기 때문이다. 세션에는 직렬화를 구현한 객체만을 저장할 수 있다. 그렇다면 User엔터티 클래스에 직렬화 코드를 넣으면 될까? 엔터티 클래스는 언제 다른 엔터티와 관계가 형성될지 모른다. 예를 들어 @OneToMany, @ManyToMany 등 자식 엔터티를 갖고 있따면 직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확룰이 높다. 그래서 직렬화 기능을 가진 세선 Dto를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 많은 도움이 된다.

로그인 버튼 만들기

Mustache를 사용한 코드로서, model에 userName이 있다면 로그아웃 버튼을 보여주고 없다면 Login버튼을 보여주는 코드를 만들어보자.

{{#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}}
  • a href="/logout"
    : 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL이다. 즉, 개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없다. SecurityConfig 클래스에서 URL을 변경할 수 있다.
  • a href="/oauth2/authorization/google"
    : 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL이다. 로그아웃과 마찬가지로 개발자가 별도의 컨트롤러를 생성할 필요가 없다.

참고