🛠️Backend/Spring

[Spring Boot] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 10

뉴발자 2023. 1. 20. 22:34
728x90

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Spring Security With Google OAuth

 

 

Application-oauth 등록

이전에 만들었던 application.properties가 있는 src/main/resources/ 디렉토리에

 

application-oauth.properties 파일을 생성한다.

 

application-oauth.properties 생성

 

생성한 파일에 아래와 클라이언트 ID와 클라이언트 보안 비밀코드를 아래와 같이 등록한다.

 

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로 인식하기 때문이다.
  • 이렇게 되면 OpenId Provider인 서비스(Google)와 그렇지 않은 서비스(네이버/카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 한다.
  • 하나의 OAuth2Service로 사용하기 위해 일부러 openid scope를 빼고 등록한다.

 

Spring Boot에서는 properties의 이름을 application-xxx.properties로 만들면

 

xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다.

 

즉, profile=xxx라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있다.

 

호출하는 방식은 여러 방식이 있지만 이 책에서는 Spring Boot의

 

기본 설정 파일인 application.properties에서 application-oauth.properties를 포함하도록 구성한다.

 

application.properties 파일에 아래의 코드를 추가해준다.

 

spring.profiles.include=oauth

 

 

 

 

.gitignore 등록

Google 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀 코드보안이 중요한 정보들이다.

 

이들이 외부에 노출될 경우 언제든 개인정보를 가져갈 수 있는 취약점이 될 수 있다.

 

이 교재로 진행중인 독자는 깃허브와 연동하여 사용하다 보니

 

application-oauth.properties 파일이 깃허브에 올라갈 수 있다.

 

보안을 위해 깃허브에 application-oauth.properties 파일이 올라가지 않게 설정해준다.

 

.gitignore 파일에 아래의 코드를 추가해준다.

 

application-oauth.properties

 

추가한 뒤 커밋 파일 목록에 application-oauth.properties가 나오지 않으면 적용된 것이다.

 

 

 

 

구글 로그인 연동하기

구글의 로그인 인증정보를 발급 받았으니 프로젝트 구현을 시작한다.

 

먼저 사용자 정보를 담당할 도메인인 User Class를 생성해준다.

 

패키지는 domain 아래에 user 패키지를 생성하여 진행한다.

 

아래와 같이 코드를 입력해준다.

 

1. User

package com.jojoIdu.book.springboot.domain.user;

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

import javax.persistence.*;

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

}

 


Enumerated(EnumType.STRING)

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

 

그리고 user 패키지 안에 각 사용자의 권한을 관리해줄 Enum Class Role을 생성한다.

 

파일 생성 시 [새로 만들기 -> Java 클래스 -> 열거형(Enum)] 으로 선택한 후 생성해주면 된다.

 

 

2.Role

package com.jojoIdu.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;

}

 

Spring Security에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 한다.

 

그래서 코드별 키 값을 ROLE_GUEST, ROLE_USER 등으로 지정한다.

 

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

 

 

3. UserRepository

package com.jojoIdu.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);

}

 


findByEmail

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

 

 

 

 

Spring Security 설정

이제 User Entity 관련 코드를 모두 작성했고 시큐리티 설정을 진행한다.

 

먼저 build.gradle 파일에 아래와 같이 의존성을 추가한다.

 

 

1. build.gradle

buildscript {
	...
}

...

dependencies {
    
    ...

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

}

 


spring-boot-starter-oauth2-client

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

 

다음으로 OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성한다.

 

com.jojoldu.book.springboot 패키지 아래에 config.auth 패키지를 생성한다.

 

앞으로 시큐리티 관련 Class를 모두 이곳에 담는다고 보면 된다.

 

 

2. config.auth 패키지 생성

config.auth 패키지 생성 위치

 

SecurityConfig Class를 생성하고 아래와 같이 코드를 작성한다.

 

아직 CustomOauth2UserService Class를 만들지 않아 컴파일 에러가 발생하지만, 코드 설명을 본 뒤 작성하면 된다.

 

 

3. SecurityConfig

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

import com.jojoIdu.book.springboot.domain.user.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

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

}

 


EnableWebSecurity

  • Spring Security 설정들을 활성화 시켜준다.

 

 

.csrf().disable().headers().frameOptions().disable()

  • h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.

 

 

authorizeRequest

  • URL별 권한 관리를 설정하는 옵션의 시작점이다.
  • authorizeRequest가 선언되어야만 antMatchers 옵션을 사용할 수 있다.

 

 

antMatchers

  • 권한 관리 대상을 지정하는 옵션이다.
  • URL, HTTP 메소드별로 관리가 가능하다.
  • "/" 등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 주었다.
  • "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 설정했다.

 

 

anyRequest

  • 설정된 값들 이외 나머지 URL들을 나타낸다.
  • 여기서 authenticated()를 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 한다.
  • 인증된 사용자는 로그인한 사용자를 이야기한다.

 

 

logout().logoutSuccessUrl("/")

  • 로그아웃 기능에 대한 여러 설정의 진입점이다.
  • 로그아웃 성공시 "/" URL로 이동한다.

 

 

oauth2Login

  • OAuth 2 로그인 기능에 대한 여러 설정의 진입점이다.

 

 

userInfoEndpoint

  • OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다.

 

 

userService

  • 소셜 로그인 성공 시 후속 조치를 진행할 UserService Interface의 구현체를 등록한다.
  • 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.

 

다음으로 CustomOAuth2UserService Class를 생성한다.

 

이 Class는 Google 로그인 이후 가져온 사용자의 정보(email, name, picture 등)들을 기반으로

 

가입 및 정보수정, 세션 저장 등의 기능을 지원한다.

 

패키지 위치는 SecurityConfig Class와 동일하다.

 

 

4. CustomOAuth2UserService

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

import com.jojoIdu.book.springboot.config.auth.dto.OAuthAttributes;
import com.jojoIdu.book.springboot.config.auth.dto.SessionUser;
import com.jojoIdu.book.springboot.domain.user.User;
import com.jojoIdu.book.springboot.domain.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpSession;
import java.util.Collections;

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

    }

}

 


registrationId

  • 현재 진행 중인 서비스를 구분하는 코드이다.
  • 지금은 구글만 사용하는 불필요한 값이지만, 이후 네이버 로그인 연동 시에 네이버로 로그인인지, 구글 로그인인지 구분하기 위해 사용한다.

 

 

userNameAttributeName

  • OAuth2 로그인 진행 시 키가 되는 필드값을 이야기한다. ( Primary Key와 같은 의미이다.)
  • 구글의 경우 기본적인 코드를 지원하지만, 네이버, 카카오 등은 기본 지원하지 않는다.
  • 구글의 기본 코드는 "sub"이다.
  • 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용된다.

 

 

OAuthAttributes

  • OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 Class이다.
  • 이후 네이버 등 다른 소셜 로그인도 이 Class를 사용한다.
  • 바로 아래에서 이 Class의 코드가 나오니 차례로 생성하면 된다.

 

 

SessionUser

  • 세션에 사용자 정보를 저장하기 위한 Dto Class이다.
  • 왜 User Class를 쓰지 않고 새로 만들어 쓰는지는 뒤이어서 상세히 설명하겠다.

 

구글 사용자 정보가 업데이트 되었을 때를 대비하여 update 기능도 같이 구현되었다.

 

사용자 이름이나 프로필 사진이 변경되면 User Entity에도 반영된다.

 

다음으로 OAuthAttributes Class 생성한다.

 

저자의 경우 OAuthAttributes는 Dto로 보기 때문에 config.auth 아래에 dto 패키지를 생성한다.

 

 

5. config.auth.dto 패키지 생성

confit.auth 하위 dto 패키지 생성

 

 

6. OAuthAttributes

package com.jojoIdu.book.springboot.config.auth.dto;

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

}

 


of()

  • OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 반환해야만 한다.

 

 

toEntity()

  • User Entity를 생성한다.
  • OAuthAttributes에서 Entity를 생성하는 시점은 처음 가입할 때이다.
  • 가입할 때의 기본 권한을 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST를 사용한다.

 

다음으로 같은 dto 패키지 안에 SessionUser Class를 추가로 생성해준다.

 

 

7. SessionUser

package com.jojoIdu.book.springboot.config.auth.dto;

import com.jojoIdu.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();
    }

}

 

SessionUser에는 인증된 사용자 정보만 필요하다.

 

그 외에 필요한 정보들은 없으니 name, email, picture만 필드로 선언해준다.

 

코드가 다 작성 되었다면 앞서 언급한 '왜 User Class를 쓰지 않고 새로 만들어서 쓰는지'에 대해 설명하겠다.

 


User Class를 사용하면 안되는 이유

만약 User Class를 그대로 사용했다면 아래와 같은 에러가 발생하게 된다.

 

Failed to convert from type [java.lang.Object] to type [byte[]] for value 
'com.jojoldu.book.springboot.domain.user.User@4a43d6'

 

이는 세션에 저장하기 위해 User Class를 세션에 저장하려고 하니,

 

User클래스에 직렬화를 구현하지 않았다는 의미이다.

 

 

그럼 오류를 해결하기 위해 User Class에 직렬화 코드만 넣으면 해결되는 것일까?

 

그것에 대해선 생각해볼 것이 많다.

 

이유는 User Class가 Entity이기 때문이다.

 

Entity Class에는 언제 다른 Entity와 관계가 형성될지 모른다.

 

 

예를 들어 @OneToMany, @ManyToMany 등 자식 Entity를 갖고 있다면


직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높다.

 

그래서 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이

 

이후 운영 및 유지보수 때 많은 도움이 된다.


 

 

 

 

로그인 테스트

모든 시큐리티 설정이 끝났고 로그인 기능 테스트를 진행한다.

 

우선 스프링 시큐리티가 잘 적용되었는지 확인하기 위해 화면에 로그인 버튼을 추가해준다.

 

index.mustache 파일에 로그인 버튼과 로그인 성공 시 사용자 이름을 보여주는 코드를 추가해준다.

 

 

1. index.mustache

{{>layout/header}}

    <h1>스프링 부트로 시작하는 웹 서비스 Ver.2</h1>
    <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>
        <!-- 목록 출력 영역 -->
 
...

 


{{#userName}}

  • mustache는 다른 언어와 같은 if문(if userName != null 등)을 제공하지 않는다.
  • true / false 여부만 판단할 뿐이다.
  • 그래서 mustache에서는 항상 최종값을 넘겨줘야만 한다.
  • 여기서도 역시 userName이 있다면 userName을 노출시키도록 구성했다.

 

 

a href="/logout"

  • Spring Security에서 기본적으로 제공하는 로그아웃 URL이다.
  • 즉, 개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없다.
  • SecurityConfig Class에서 URL을 변경할 순 있지만 기본 URL을 사용해도 충분하니 그대로 사용한다.

 

 

{{^userName}}

  • mustache에서 해당 값이 존재하지 않는 경우에는 ^ 를 사용한다.
  • 여기서는 userName이 없다면 로그인 버튼을 노출시키도록 구성했다.

 

 

a href="/oauth2/authorization/google"

  • Spring Security에서 기본적으로 제공하는 로그인 URL이다.
  • 로그아웃 URL과 마찬가지로 개발자가 별도의 컨트롤러를 생성할 필요가 없다.

 

index.mustache에서 userName을 사용할 수 있게 IndexController에서

 

userName을 model에 저장하는 코드를 추가해준다.

 

 

2. IndexController

package com.jojoIdu.book.springboot.web;

...
import javax.servlet.http.HttpSession;

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

...

}

 


(SessionUser) httpSession.getAttribute("user")

  • 앞서 작성된 CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했다.
  • 즉, 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있다.

 

 

if(user != null)

  • 세션에 저장된 값이 있을 때만 model에 userName으로 등록한다.
  • 세션에 저장된 값이 없으면 model엔 아무런 값이 없는 상태이니 로그인 버튼이 보이게 된다.

 

그럼 한번 프로젝트를 실행해서 테스트를 해보겠다.

 

http://localhost:8080 URL로 접속 시 Google Login 버튼이 잘 노출된다.

 

Google Login 버튼

 

클릭해 보면 평소 다른 서비스에서 볼 수 있던 것처럼 Google 로그인 동의 화면으로 이동하게 된다.

 

Google 로그인 동의 화면

 

본인의 계정을 선택하면 로그인 과정이 진행된다.

 

로그인에 성공하면 다음과 같이 구글 계정에 등록된 이름이 화면에 노출되는 것을 확인할 수 있다.

 

Google 로그인 성공 화면

 

회원가입도 잘 되어 있는지 확인해본다.

 

http://localhost:8080/h2-console 로 접속해서 user 테이블을 확인한다.

 

h2-console의 user테이블 등록 확인

 

DataBase에 정상적으로 회원정보가 들어간 것을 확인할 수 있다.

 

또한, 권한 관리도 잘되는지 확인해 보겠다. 현재 로그인된 사용자의 권한은 GUEST이다.

 

이 상태에서는 posts 기능을 전혀 쓸 수 없다.

 

실제로 글 등록 기능을 사용해보도록 하겠다.

 

GUEST 권한으로 글 등록시 나오는 오류(403 : 권한 거부)

 

테스트를 위한 글쓰기를 진행하면 위와 같이 403(권한 거부) 에러가 발생하는 것을 볼 수 있다.

 

그럼 권한을 변경해서 다시 시도해 보겠다.

 

http://locahost:8080/h2-console 로 가서 사용자의 role을 User로 변경한다.

 

update user set role = 'USER';

 

권한을 USER로 설정해준다.

 

세션에는 이미 GUEST인 정보로 저장되어있으니 로그아웃한 후 다시 로그인하여 세션 정보를 갱신해준다.

 

그리고 글등록을 다시 시도하면 아래와 같이 정상적으로 글이 등록되는 것을 확인할 수 있다.

 

게시글 등록 성공

 

게시글 등록 성공

 

 

기본적인 구글 로그인, 로그아웃, 회원가입, 권한관리 기능이 모두 구현되었다.

 

 

 

다음번에는 로그인 기능을 개선하는 방법을 포스팅하겠다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

728x90