Chapter 05 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 2

LeeKyoungChang·2022년 5월 19일
0
post-thumbnail

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 를 공부하고 정리한 내용입니다.

 

구글 로그인을 프로젝트에 적용해보기

📚 1. 구글 로그인 연동하기

📖 A. User, Role, UserRepository 생성

✔️ User 클래스

User 클래스는 사용자 정보를 담당할 도메인

domain/user/User.java

package springbootawsbook.springawsbook.domain.user;  
  
import lombok.Builder;  
import lombok.Getter;  
import lombok.NoArgsConstructor;  
import springbootawsbook.springawsbook.domain.BaseTimeEntity;  
  
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로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정한다.
    • 기본적으로는 int로 된 숫자가 저장되는데, 숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수가 없다.
    • 그래서 문자열(EnumType.STRING)로 저장될 수 있도록 선언한다.

 

✔️ Role

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

package springbootawsbook.springawsbook.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;  
}

스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 한다.
그래서 코드별 키 값을 ROLE_GUESTROLE_USER 등으로 지정한다.

 

✔️ UserRepository

User의 CRUD를 책임질 UserRepository를 생성

package springbootawsbook.springawsbook.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을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드이다.

 

📖 B. 스프링 시큐리티 설정

✔️ build.gradle에 스프링 시큐리티 관련 의존성 추가

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

 

config.auth 패키지 : 시큐리티 관련 클래스는 모두 이 곳에 담는다.

✔️ SecurityConfig
OAuth 라이브러리를 이용한 소셜 로그인 설정 코드 작성

package springbootawsbook.springawsbook.config.auth;  
  
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;  
import springbootawsbook.springawsbook.domain.user.Role;  
  
@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 한다.
  • authorizeRequests
    • URL별 권한 관리를 설정하는 옵션의 시작점이다.
    • authorizeRequests가 선언되어야만 antMatchers 옵션을 사용할 수 있다.
  • antMatchers
    • 권한 관리 대상을 지정하는 옵션이다.
    • URL, HTTP 메소드별로 관리가 가능하다.
    • "/" 등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 주었다.
    • "/api/v1/**"/ 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 했다.
  • anyRequest
    • 설정된 값들 이외 나머지 URL들을 나타낸다.
    • 여기서는 authenticated()을 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 한다.
    • 인증된 사용자 즉, 로그인한 사용자들을 말한다.
  • logout().logoutSuccessUrl("/")
    • 로그아웃 기능에 대한 여러 설정의 진입점이다.
    • 로그아웃 성공 시 / 주소로 이동한다.
  • oauth2Login
    • OAuth2 로그인 기능에 대한 여러 설정의 진입점이다.
  • userInfoEndpoint
    • OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다.
  • userService
    • 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록한다.
    • 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.

 

✔️ CustomOAuth2UserService

구글 로그인 이후 가져온 사용자의 정보(email, name, picture 등)들을 기반으로 가입 및 정보 수정, 세션 저장 등의 기능을 지원한다.

package springbootawsbook.springawsbook.config.auth;  
  
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 springbootawsbook.springawsbook.config.auth.dto.OAuthAttributes;  
import springbootawsbook.springawsbook.config.auth.dto.SessionUser;  
import springbootawsbook.springawsbook.domain.user.User;  
import springbootawsbook.springawsbook.domain.user.UserRepository;  
  
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 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를 담을 클래스이다.
    • 이후 네이버 등 다른 소셜 로그인도 이 클래스를 사용한다.
  • SessionUser
    • 세션에 사용자 정보를 저장하기 위한 Dto 클래스이다.
    • User 클래스를 쓰지 않고 새로 만들어서 쓰는 이유는 바로 밑에서 설명하도록 하겠다.

구글 사용자 정보가 업데이트 되었을 때를 대비하여 update 기능도 같이 구현되었다.
사용자의 이름이나 프로필 사진이 변경되면 User 엔티티에도 반영된다.

 

✔️ OAuthAttributes

package springbootawsbook.springawsbook.config.auth.dto;  
  
import lombok.Builder;  
import lombok.Getter;  
import springbootawsbook.springawsbook.domain.user.Role;  
import springbootawsbook.springawsbook.domain.user.User;  
  
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) {  
        if("naver".equals(registrationId)) {  
            return ofNaver("id", 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();  
    }  
  
    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {  
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");  
  
        return OAuthAttributes.builder()  
                .name((String) response.get("name"))  
                .email((String) response.get("email"))  
                .picture((String) response.get("profile_image"))  
                .attributes(response)  
                .nameAttributeKey(userNameAttributeName)  
                .build();  
    }  
  
    public User toEntity() {  
        return User.builder()  
                .name(name)  
                .email(email)  
                .picture(picture)  
                .role(Role.GUEST)  
                .build();  
    }  
}
  • of()
    • OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야만 한다.
  • toEntity()
    • User 엔티티를 생성한다.
    • OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때이다.
    • 가입할 때의 기본 권한을 GUEST로 주기 때문에 role 빌더값에는 Role.GUEST를 사용한다.
    • OAuthAttributes 클래스 생성이 끝났으면 같은 패키지에 SessionUser 클래스를 생성한다.

 

✔️ SessionUser

package springbootawsbook.springawsbook.config.auth.dto;  
  
import lombok.Getter;  
import springbootawsbook.springawsbook.domain.user.User;  
  
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 클래스를 사용하지 않고 SessionUser dto를 만드는 이유
: User 클래스를 그대로 사용하면 직렬화를 구현하지 않았다는 의미의 에러가 발생하게 된다.

 

오류를 해결하기 위해 User 클래스에 직렬화 코드를 넣기에는 User 클래스가 엔티티이기 때문에 좋은 방법이 아니다.
엔티티가 만약 자식 엔티티를 가지고 있다면 직렬화 대상에 자식들까지 포함되어 성능 이슈, 부수 효과가 발생할 확률이 높다.
그래서 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 많은 도움이 된다.

 

📚 2. 로그인 테스트

스프링 시큐리티가 잘 적용되었는지 확인하기 위해 화면에 로그인 버튼을 추가해보자!

 

✔️ index.mustache

...
<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>  
                <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>  
            {{/userName}}  
        </div>  
    </div>  
    <br>  
    <!-- 목록 출력 영역 -->
		...
  • {{#userName}}
    • 머스테치는 다른 언어와 같은 if문 (if userName != null 등)을 제공하지 않고, true/false 여부만 판단한다.
    • 그래서 머스테치에서는 항상 최종값을 넘겨줘야 한다.
    • 여기서도 userName이 있다면 userName을 노출시키도록 구성했다.
  • a href="/logout"
    • 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL으로, 개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없다.
    • SecurityConfig 클래스에서 URL을 변경할 수 있지만, 여기서는 그대로 사용했다.
  • {{^userName}}
    • 머스테치에서 해당 값이 존재하지 않는 경우에는 ^를 사용한다.
    • 여기서는 userName이 없다면 로그인 버튼을 노출시키도록 구성했다.
  • a href="/oauth2/authorization/google"
    • 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL이다.
    • 로그아웃 URL과 마찬가지로 개발자가 별도의 컨트롤러를 생성할 필요가 없다.

 

✔️ IndexController

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

public class IndexController {

	...
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("post", postService.findAllDesc());
        SessionUser user = (SessionUser) httpSession.getAttribute("user");

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

        return "index";
    }
	...
}
  • (SessionUser) httpSessions.getAttribute("user")
    • 앞서 작성된 CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했다.
    • 즉, 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있다.
  • if (user != null)
    • 세션에 저장된 값이 있을 때만 model에 userName으로 등록한다.
    • 세션에 저장된 값이 없으면 model에 아무런 값이 없는 상태이니 로그인 버튼이 보이게 된다.

 

✔️ 실행 결과

스크린샷 2022-05-19 오후 6 50 00
  • 현재 구글 로그인 버튼이 등록되어 있다.

 

스크린샷 2022-05-19 오후 6 50 10
  • 버튼을 클릭하니, 구글 계정을 선택할 수 있다.

 

스크린샷 2022-05-19 오후 6 50 24
  • 로그인이 성공하면 이와 같이 구글 계정에 등록된 이름이 화면에 노출된다.

 

회원 가입이 잘 되었는지 확인하기 위해 h2-console에 접속해서 USER 테이블을 확인해보자!

스크린샷 2022-05-19 오후 6 50 51
  • 데이터베이스에 정상적으로 회원정보가 들어간 것을 확인할 수 있다.

 

권한 관리도 잘되는지 확인해보자.

스크린샷 2022-05-19 오후 6 51 14 스크린샷 2022-05-19 오후 6 51 24
  • 현재 로그인된 사용자의 권한은 GUEST이므로 posts 기능을 전혀 쓸 수 없는 상태이므로 에러가 발생한다.
  • 게시글 제목, 작성자, 내용을 입력하고 등록 버튼을 클릭하니 403(권한 거부) 에러가 발생한 것을 볼 수 있다.

 

권한을 변경하자!

스크린샷 2022-05-19 오후 6 52 31
update user set role = 'USER';
  • h2-console로 가서 사용자의 role을 USER로 변경해보자

 

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

스크린샷 2022-05-19 오후 6 53 02
  • 이와 같이 정상적으로 글이 등록되는 것을 확인할 수 있다.

 

현재는 기본적인 구글 로그인, 로그아웃, 회원 가입, 권한 관리 기능이 모두 구현되었다.
다음부터는 기능 개선을 진행해볼 것이다.

 

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글