Spring Security로 구글 로그인 추가하기

구글 환경 설정

 https://console.cloud.google.com/apis/dashboard

 

Google 클라우드 플랫폼

로그인 Google 클라우드 플랫폼으로 이동

accounts.google.com

1. 위 링크에서 프로젝트를 생성한다.

2. 생성이 완료된 프로젝트를 선택하고 왼쪽 탭에서 사용자 인증 정보에 들어가 사용자 인증 정보 만들기에서 OAuth 클라이언트 ID를 선택한다.

그러면 먼저 클라이언트 ID를 생성하기 전에 동의화면 구성이 필요하므로 동의화면 구성 설정이 뜬다.

3. UserType은 외부로 선택한다.

4. 사용할 애플리케이션 이름, 입력하라고 하는 이메일 등을 작성한다.

5. 범위 추가 또는 삭제에서 다음 두가지를 선택한다.

여기까지 하고 다시 OAuth 클라이언트 ID만들기 화면으로 이동한다.

---

1. 애플리케이션 유형을 선택한다. (나는 웹 애플리케이션!)

2. 승인된 리디렉션 URI를 추가한다.

아직 서버에 배포하지 않아서 우선 테스트 용으로 localhost:8080으로 배포한다.

스프링 부트 2버전 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 url을 지원한다.

3. 생성이 완료되면 다음과 같이 클라이언트 id와 보안 비밀번호가 생성된다.

.

스프링 환경 설정

1.src/main/resources에 application-oauth.properties 파일 생성

해당 파일에 클라이언트 ID와 클라이언트 보안 비밀 코드를 추가한다.

spring.security.oauth2.client.registration.google.client-id=클라이언트아이디
spring.security.oauth2.client.registration.google.client-secret=클라이언트보안비밀코드
spring.security.oauth2.client.registration.google.scope=profile,email

scope에 profile과 email을 등록한 이유는 openid라는 scope가 자동으로 생기면 OpenId Provider로 인식한다.

이렇게 되면 OpenId Provider인 서비스와 그렇지 않는 서비스(네이버 카카오 등)으로 나눠서 OAuth2Serivce를 나눠서 만들어야한다. 하나로 사용하고 싶다면 openid scope를 제거해주자.

2.application.properties에 추가

spring.profiles.include=oauth

위와 같은 코드를 추가한다.

properties파일의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성된다.

profile=xxx로 호출하면 해당 properties의 설정들을 가져올 수 있기 때문에 위와같이 추가해준다.

3. .gitignoer 등록

클라이언트 id와 클라이언트 보안 비밀이 그대로 git에 노출되면 취약하다. 따라서 이것이 올라가는 것을 방지하기 위해 .gitignore에 해당 파일이 올라가지 않도록 코드를 반드시 추가한다.

gitignore에 추가했는데도 그대로 깃허브에 올라가는 문제가 발생했다.

해결법

https://jojoldu.tistory.com/307

 

.gitignore가 작동하지 않을때 대처법

.gitignore가 제대로 작동되지 않아서 ignore처리된 파일이 자꾸 changes에 나올때가 있습니다. git의 캐시가 문제가 되는거라 아래 명령어로 캐시 내용을 전부 삭제후 다시 add All해서 커밋하시면 됩니

jojoldu.tistory.com

 

스프링 코드들

User의 Role을 구분해주기 위해 enum으로 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는 다음과 같이 만들어준다.

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
public class User {
    @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로 DB에 저장할때 Enum 값을 어떤 형태로 저장할지를 결정한다. default는 int인데 int로 전달되면 DB를 볼 때 정확히 무슨 값을 의미하는지 알기 어렵기 때문에 String으로 지정해준다.

 

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

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

 

config.auth 패키지안에 SecurityConfig 클래스를 생성한다.

import com.practice.springboot.book.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 설정들을 활성화 시켜준다.

 

authorizeRequests()

URL별 권한 관리를 설정하는 옵션의 시작점이다.

이게 선언되어야만 antMatchers 옵션 사용이 가능하다.

 

antMatchers

권한 관리 대상을 지정하는 옵션이다.

URL, HTTP 메소드 별로 관리가 가능하다.

/등 지정된 URL들은 permitAll()옵션을 통해 전체 열람 권한을 준다.

/api/v1/** 주소를 가진 api는 USER권한을 가진 사람들만 가능하다.

 

anyRequest

설정값들 이외의 나머지 url을 의미한다. 여기서는 authenticated()로 나머지 모든 URL들을 모두 인증된 사용자(로그인 한 사람)에게만 허용한다.

 

logout().logoutSuccessUrl("/")

로그아웃 성공시 / 주소로 이동한다는 의미

 

oauth2Login

OAuth2 로그인 기능에 대한 여러 설정의 진입점

 

userInfoEndpoint

OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당

 

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

getRegistrationId()

현재 로그인 진행 중인 서비스를 구분하는 코드 (구글 외에 다른 로그인이 추가 되었을 때 지금이 구글인지 네이버인지 등등을 분류할 때 사용됨)

 

userNameAttributeName()

OAuth2 로그인 진행 시 키가 되는 필드 값을 의미한다. Primary Key같은 것

구글의 경우 기본적으로 코드를 지원하지만 네이버와 카카오는 지원하지 않는다. 구글의 기본 코드는 "sub"이다.

 

saveOrUpdate에 의해 구글 사용자 정보가 업데이트 되었을 때 User 엔티티에도 반영이 된다.

 

OAuthAttributes

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

OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스이다.

 

of()가 사용되는 이유는 OAuth2User에서 반환하는 사용자 정보가 Map이기 때문에 값 하나하나를 반환해야한다.

 

SessionUser

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

세션에 사용자 정보를 저장하기 위한 Dto 클래스이다.

왜 User클래스를 재사용하지 않고 SessionUser를 따로 만들까?

기존의 User 클래스에서 직렬화를 구현하지 않았다면 User를 그대로 사용하면 에러가 발생한다.

User는 지금 JPA를 사용한 엔티티이기 때문에 언제 다른 엔티티와 관계가 형성될지 모르기 때문에 직렬화를 쉽게 할 수 없다. 다른 엔티티와 관계가 생기면 그 관련 자식들 까지 모두 직렬화의 대상이 되므로 성능이슈 등의 문제가 발생할 확률이 높다. 따라서 직렬화를 가진 세션 dto를 따로 추가해주는 것이 이후 운영 및 유지보수에 도움이 된다.

'공부기록 > 스프링' 카테고리의 다른 글

Spring(boot) project pom.xml/build.gradle 작성  (0) 2024.04.16
스프링 JPA(Java Persistence API) (1)  (0) 2023.05.24
스프링 view 구현 (jsp)  (0) 2023.05.23
스프링 HTTP Session 사용  (0) 2023.05.22
Spring MVC(2) - Form처리  (0) 2023.05.22
myoskin