본문 바로가기
스프링 부트와 AWS

05장. 스프링 시큐리티와 OAuth2.0으로 로그인 기능 구현하기03

by danny-j 2022. 11. 15.

구글 로그인 연동하기


application-oauth.properties 파일 생성

# Google
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인 서비스(구글)와 그렇지 않은 서비스(네이버, 카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 함
  • 하나의 OAuth2Service로 사용하기 위해 일부러 openid scope를 빼고 등록

 

application.properties에 코드 추가

spring.profiles.include=oauth
  • 스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있음
  • 즉, profile=xxx라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있음
  • 호출하는 방식은 여러가지가 있지만 교재에서는 스프링 부트의 기본 설정 파일인 application.properties에서 application-oauth.properties를 포함하도록 구성

 

.gitignore 등록

application-oauth.properties
  • 구글 로그인을 위한 클라이언트 ID/보안 비밀 코드는 보안이 중요한 정보
  • 이들이 외부에 노출될 경우 언제든 개인정보를 가져갈 수 있는 취약점이 될 수 있음
  • application-oauth.properties 파일이 깃허브에 올라가는 것을 방지할 것

 

domain패키지 아래에 user 패키지를 생성 후 User클래스 생성

package com.danny.makewebalone.web.domain.user;

import com.danny.makewebalone.service.posts.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@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)로 저장될 수 있도록 선언

 

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

package com.danny.makewebalone.web.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_GUEST, ROLE_USER 등으로 지정함

 

User의 CRUD를 책임질 UserRepository 생성

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

스프링 시큐리티 설정


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 클래스 생성

package com.danny.makewebalone.config.auth;

import com.danny.makewebalone.web.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/**", "/profile").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 com.danny.makewebalone.config.auth;

import com.danny.makewebalone.config.auth.dto.OAuthAttributes;
import com.danny.makewebalone.config.auth.dto.SessionUser;
import com.danny.makewebalone.web.domain.user.User;
import com.danny.makewebalone.web.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 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클래스를 쓰지 않고 새로 만들어서 쓰는걸까?

 

왜? User클래스를 쓰지 않고 SessionUser클래스를 새로 만들어서 쓰나?

  • User클래스를 세션에 저장하려고 하면 User클래스에 직렬화를 구현하지 않았다는 의미의 에러가 발생함
  • 그럼 오류를 해결하기 위해 User클래스에 직렬화 코드를 넣으면 될까?
  • User클래스는 엔티티 클래스이기 때문에 언제 다른 엔티티클래스와 관계가 형성될지 모른기 때문에 성능 이슈, 부수 효과가 발생할 확률이 높음
  • 때문에 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 이후 운영 및 유지보스 때 많은 도움이 됨

 

OAuthAttritubes 클래스 생성

package com.danny.makewebalone.config.auth.dto;

import com.danny.makewebalone.web.domain.user.Role;
import com.danny.makewebalone.web.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) {
        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클래스를 생성

 

config.auth.dto 패키지에 SessionUser 클래스 추가

package com.danny.makewebalone.config.auth.dto;

import com.danny.makewebalone.web.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만 필드로 선언

 

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>
            {{/userName}}
        </div>
    </div>
    <br>
...

#userName

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

a href="/logout"

  • 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL임
  • 즉, 개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없음
  • SecurityConfig 클래스에서 URL을 변경할 순 있지만 기본 URL을 사용해도 충분

^userName

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

a href="/oauth2/authorization/google"

  • 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL임
  • 로그아웃 URL과 마찬가지로 개발자가 별도의 컨트롤러를 생성할 필요가 없음

 

 

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

    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엔 아무런 값이 없는 상태이니 로그인 버튼이 보이게 됨

 

​드디어 결과 확인

  • 로그인 버튼 정상적으로 노출

 

  • 로그인 버튼 클릭시 정상적으로 구글 로그인으로 이동

 

  • 정상적으로 로그인 성공

 

  • 글쓰기에서 오류 발생

 

  • User권한이 GUEST이기 때문에 정상적인 오류

 

  • 유저 권한을 GUEST에서 USER로 업데이트

 

  • 정상적으로 권한 변경

 

Success

댓글