Spring

[스프링 부트와 AWS로 혼자 구현하는 웹 서비스] 5. 스프링 시큐리티, OAuth2.0, Thymeleaf로 소셜로그인 기능 구현하기

주니어주니 2023. 8. 21. 00:01

 

1. 구글 로그인

 

1) 구글 로그인 설정

 

https://darrenlog.tistory.com/38

 

[OAuth 2.0] 개념과 Google OAuth2.0 세팅부터 스프링부트로 구현-1

안녕하세요. 이번 게시글에서는 OAuth 2.0에 대한 간략한 개념과 스프링부트 환경에서 직접 구현해보는 단계까지 다뤄보도록 하겠습니다. OAuth, Open Authorization 정의 OAuth는 인터넷 사용자들이 비밀

darrenlog.tistory.com

 

 

2) 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

 

+) application.properties 에서 include 하도록 설정

# oauth 포함
spring.profiles.include=oauth

 

 

3) .gitignore 등록

.gitignore 파일에 application-oauth.properties 작성

 

 💡 근데 .gitignore 파일에 작성해도 무시가 안되는 경우

cd C:\study\study-springboot-webservice (프로젝트 경로)

git rm -r --cached .
git add .
git commit -m "Fix .gitignore and untrack application-oauth.properties"

 

 

4) build.gradle - 스프링 시큐리티 설정

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

 

 

5) User - 사용자 정보를 담는 객체 

package com.example.studyspringbootwebservice.domain.user;

import com.example.studyspringbootwebservice.domain.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@Table(name = "users")
@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();	// 'ROLE_GUEST', 'ROLE_USER'
    }
}
  • @Table
    • 이름을 따로 users로 설정하지 않으면 자바의 예약어 user와 겹쳐서 sql 오류

  • @Enumerated(EnumType.STRING)
    • JPA로 데이터베이스 저장할 때 Enum 값을 어떤 형태로 저장할지 결정
    • 기본적으로는 int 저장 -> 그 값이 무슨 코드를 의미하는지 알 수 없음

 

 

6) Role - 사용자의 권한 관리 Enum 클래스 

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

}

 

 

7) UserRepository - User의 CRUD

package com.example.studyspringbootwebservice.domain.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    // 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드
    Optional<User> findByEmail(String email);
}

 

 

8) SecurityConfig

package com.example.studyspringbootwebservice.config.auth;

import com.example.studyspringbootwebservice.domain.user.Role;
import jakarta.servlet.DispatcherType;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@RequiredArgsConstructor
@EnableWebSecurity	// Spring Security 설정 활성화
@Configuration
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    protected SecurityFilterChain config(HttpSecurity http) throws Exception {
        http
                .csrf().disable()                           // 토큰 검증 비활성화
                .headers().frameOptions().disable()         // h2-console 화면을 사용하기 위해 해당 옵션들 disable
                .and()
                    .authorizeHttpRequests(                 // http 요청에 대한 인증, 권한 설정 수행
                            request -> request
                                    .dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll()     // Forwad 요청에 대해 모든 사용자의 접근 허용
                                    .requestMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
                                    .requestMatchers("/api/v1/**").hasRole(Role.USER.name())      // 해당 경로에 대해서는 USER 권한을 가진 사용자만 접근 허용
                                    .anyRequest().authenticated()                                   // 이외의 모든 요청은 인증된 사용자만 접근 허용
                    )
                    .logout().logoutSuccessUrl("/")
                .and()
                    .oauth2Login()                              // oauth2.0 로그인 설정
                        .userInfoEndpoint()                     // 로그인 성공한 사용자 정보를 가져올 때의 설정
                        .userService(customOAuth2UserService);  // 로그인 성공 시 수행할 service 등록
        return http.build();
    }
}
  • WebSecurityConfigurerAdapter 상속받아서 configure 메소드를 오버라이드 하는데 deprecated 됐음
  • WebSecurityConfigurerAdapter ---> SecurityFilterChain을 스프링 빈에 등록해서 사용
  • antMatchers ---> requestMatchers
  • authorizeHttpRequests() : URL별 권한 관리를 설정하는 옵션의 시작. 얘가 있어야 requestMatchers 옵션 쓸 수 있음

 

 

9) CustomOAuth2UserService - 로그인한 사용자 정보를 가지고 수행할 작업

package com.example.studyspringbootwebservice.config.auth;

import com.example.studyspringbootwebservice.config.auth.dto.OAuthAttributes;
import com.example.studyspringbootwebservice.config.auth.dto.SessionUser;
import com.example.studyspringbootwebservice.domain.user.User;
import com.example.studyspringbootwebservice.domain.user.UserRepository;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 java.util.Collections;

@RequiredArgsConstructor
@Service
@Slf4j
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        // 기본적으로 제공되는 DefaultOAuth2UserSevice 사용 : OAuth 2.0 공급자로부터 받은 액세스 토큰을 사용하여 사용자 정보를 가져옴
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        // 사용자 정보
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        // 현재 로그인 진행 중인 OAuth 2.0 서비스를 구분하는 코드
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        // OAuth 2.0 로그인 진행 시 키가 되는 필드값. 구글은 기본적으로 코드 지원(sub)
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        // OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        // 조회한 OAuth2User가 있을 경우 update, 없을 경우 save
        User user = saveOrUpdate(attributes);

        // 세션에 사용자 정보를 저장하기 위한 Dto 클래스
        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);
    }
}
  • oAuth2User의 attributes
    • 사용자 정보
// 구글의 attribute 구조

{
     sub=12343941,
     name=김준희,
     given_name=준희,
     family_name=김,
     picture=https://~~~, 
     email=이메일주소, 
     email_verified=true, 
     locale=ko
}

 

 

  • registrationId = google
    • 현재 로그인 진행 중인 OAuth2.0 서비스 구분

  • userNameAttributename = sub
    • 로그인 진행 시 키가 되는 필드값
    • 구글은 기본적으로 코드 지원 (sub)

  • attribute = OAuthAttributes 객체
    • OAuth2User의 attribute를 담을 OAuthAttributes 클래스

  • user = User
    • 로그인한 OAuth2User 정보가 이미 있을 경우 업데이트, 없을 경우 생성한 뒤 저장된 User 객체
    • 이 객체를 HttpSession 세션의 attribute에 저장

  • new SessionUser(user)
    • 객체의 상태를 일련의(serial) 바이트로 변환하여 저장
    • 자바 시스템 간 데이터 교환하기 위함
    • 로그인한 사용자의 정보를 세션에 저장하고 전달하기 위해 직렬화된 객체 사용
    • 세션에 저장할 때, 직렬화하여 저장하면 사용자가 로그인 상태를 유지하는 동안 세션 데이터를 유지할 수 있음

 

 

10) OAuthAttributes - 로그인한 사용자의 attribute 정보를 담음

package com.example.studyspringbootwebservice.config.auth.dto;

import com.example.studyspringbootwebservice.domain.user.Role;
import com.example.studyspringbootwebservice.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();
    }

    // OAuthAttributes에서 엔티티 생성하는 시점은 처음 가입할 때-> ROLE_GUEST
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }

}

 

 

11) SessionUser - 인증된 사용자 정보를 세션에 저장하기기 위한 직렬화된 객체

package com.example.studyspringbootwebservice.config.auth.dto;

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

 

 

12) IndexController - 사용자 이름을 뷰에서 사용할 수 있도록 모델에 담음

@RequiredArgsConstructor
@Controller
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";
    }

 

 

13) index.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>

<body>

    <h1>스프링 부트로 시작하는 웹 서비스</h1>

    <div class="col-md-12">
        <!-- 로그인 기능 영역 -->
        <div class="row">
            <div class="col">
                <a href="/posts/save" role="button" class="btn btn-dark">글 등록</a>
            </div>
            <div th:if="${userName}" class="col">
                Logged in as : <span id="user" th:text="${userName}"></span>
                <a th:href="@{/logout}" class="btn btn-secondary active" role="button">Logout</a>
            </div>
            <div th:unless="${userName}" class="col">
                <a th:href="@{/oauth2/authorization/google}" class="btn btn-primary active" role="button">Google Login</a>
            </div>
        </div>
        <br/>

        <!-- 목록 출력 영역 -->
        <table class="table table-horizontal table-bordered">
            <thead class="thead-strong">
            <tr>
                <th>게시글번호</th>
                <th>제목</th>
                <th>작성자</th>
                <th>최종수정일</th>
            </tr>
            </thead>
            <tbody id="tbody">
            <tr th:each="post : ${posts}">
                <td th:text="${post.id}">id</td>
                <td><a th:href="@{/posts/update/{id}(id = ${post.id})}" th:text="${post.title}">title</a></td>
                <td th:text="${post.author}">author</td>
                <td th:text="${post.modifiedDate}">modifiedDate</td>
            </tr>
            </tbody>
        </table>
    </div>

    <!-- footer 조각 -->
    <div th:replace="~{fragments/footer :: main-footer}"></div>

</body>
</html>

 

 

 

 

 

테이블 등록 확인

 

 

게시글 등록 시 -> 403 (권한 거부)

 

테이블에서 role을 USER로 변경 -> 로그아웃 후 글 등록

 

 

 


 

2. 로그인 개선하기

 

1) @LoginUser 어노테이션 기반으로 개선

 

 

* IndexController

@RequiredArgsConstructor
@Controller
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 user = (SessionUser) httpSession.getAttribute("user");
    • 세션 정보가 필요할 때마다 위처럼 세션 값을 가져와야 함 -> 같은 코드 반복 

 

 

(1) @LoginUser 어노테이션 생성 

package com.example.studyspringbootwebservice.config.auth;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)      	// 이 어노테이션이 생성될 수 있는 위치 지정(메소드의 파라미터로 선언된 객체에서만 사용)
@Retention(RetentionPolicy.RUNTIME)	// 런타임 시 클래스파일에서 유지
public @interface LoginUser {
}

 

 

(2) LoginUserArgumentResolver - 컨트롤러의 메소드로 로그인한 사용자의 세션 정보를 쉽게 주입

package com.example.studyspringbootwebservice.config.auth;

import com.example.studyspringbootwebservice.config.auth.dto.SessionUser;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    // 컨트롤러 메소드의 특정 파라미터를 지원하는지 판단
    // 파라미터에 @LoginUser 어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
        return isLoginUserAnnotation && isUserClass;
    }

    // 파라미터에 전달할 객체 생성
    // 세션에서 객체 가져옴
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}
  • HandlerMethodArgumentResolver 구현
    • 컨트롤러 메소드의 매개변수를 해결하는 역할
    • 구현 클래스를 통해 컨트롤러 메소드의 매개변수에 값을 제공하는 사용자 지정 로직을 정의할 수 있음
    • 주로 요청에 따라 컨트롤러 메소드에 전달되는 매개변수의 값을 추출, 변환하는 작업

  • supportsParameter(MethodParameter parameter)
    • 특정 파라미터에 리졸버 적용 가능한지 판단
    • @LoginUser 어노테이션이 붙어있는지 + 파라미터 타입이 Session 클래스인지 확인

  • resolveArgument()
    • 실제로 매개변수 값 해결(추출, 변환, 생성 등 작업)하고 반환
    • HttpSession에 들어있는 "user" 속성을 찾아서 반환

 

 

(3) WebConfig - 위 리졸버를 스프링이 인식할 수 있도록 등록

package com.example.studyspringbootwebservice.config;

import com.example.studyspringbootwebservice.config.auth.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;

    // HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가해야 함
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserArgumentResolver);
    }
}
  • Argument Resolver 등록

 

 

(4) IndexController - @LoginUser 적용

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());

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

        return "index";
    }
  • @LoginUser 어노테이션 확인 + SessionUser 클래스 타입 확인 -> HttpSession에 있는 "user" 속성 반환

 

 

2) 세션 저장소로 데이터베이스 사용하기 

 

  • 세션 저장소
    1. 톰캣 세션
      • 기본적으로 선택되는 방식
      • 톰캣(WAS)에 세션 저장 -> 애플리케이션 실행 시마다 항상 세션 초기화
      • 2대 이상의 WAS 구동 환경 -> 톰캣 간 세션 공유를 위한 추가 설정 필요

    2. MySQL 같은 데이터베이스 (얘로 사용)
      • 여러 WAS 간 공용 세션 사용할 수 있는 쉬운 방법
      • 설정이 간단함. 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도
      • 로그인 요청마다 DB IO 발생 -> 성능 이슈

    3. Redis 같은 메모리 DB
      • B2C에서 많이 사용
      • 사용료 지불해야 함
      • 실제 서비스로 사용하기 위해서는 임베디드 Redis가 아닌 외부 메모리 서버 필요 

 

 

(1) build.gradle - 의존성 등록

// spring session jdbc
implementation 'org.springframework.session:spring-session-jdbc'

 

(2) application.properties

# 세션저장소를 jdbc로 선택
spring.session.store-type=jdbc

 

 


 

 

3. 네이버 로그인

 

1) 네이버 로그인 설정

 

https://developers.naver.com/apps/#/register

 

애플리케이션 - NAVER Developers

 

developers.naver.com

 

 

 

2) application-oauth.properties

# naver
spring.security.oauth2.client.registration.naver.client-id=클라이언트 id
spring.security.oauth2.client.registration.naver.client-secret=클라이언트 secret 키
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver

spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
  • user-name-attribute=response
    • 기준이 되는 user_name의 이름을 네이버에서는 response로 함
    • 네이버의 회원 조회 시 반환되는 attribute 구조 중 최상위 필드는 resultCode, message, response
    • 스프링 시큐리티에서는 하위 필드를 명시할 수 X, 최상위 필드만 user_name으로 지정 가능
    • 위 3개의 필드 중 하나를 user_name으로 지정해야 함
    • response의 id를 user_name으로 지정

 

// 네이버의 attributes 구조

{
	"resultcode": "00",
        "message": "success", 
        "response": {
            "email": "openapinaver.com",
            "nickname": "OpenAPI",
            "profile_image": "https://~~",
            "age": "40-49",
            "gender": "F",
            "id": "327489194",
            "name": "오픈API",
            "birthday": "10-01"
        }
}

 

 

3) OAuthAttributes - 로그인한 사용자의 attribute 정보를 담은 객체

package com.example.studyspringbootwebservice.config.auth.dto;

import com.example.studyspringbootwebservice.domain.user.Role;
import com.example.studyspringbootwebservice.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) {

        // 네이버인지 판단, response의 id를 user_name으로 지정
        if ("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }
        return ofGoogle(userNameAttributeName, attributes);
    }

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

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

    // OAuthAttributes에서 엔티티 생성하는 시점은 처음 가입할 때
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }

}

 

 

* CustomOAuth2UserService - 로그인한 OAuth2 서비스에 따라 OAuthAttributes 가져옴

@RequiredArgsConstructor
@Service
@Slf4j
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        // 기본적으로 제공되는 DefaultOAuth2UserSevice 사용 : OAuth 2.0 공급자로부터 받은 액세스 토큰을 사용하여 사용자 정보를 가져옴
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        // 사용자 정보
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        // 현재 로그인 진행 중인 OAuth 2.0 서비스를 구분하는 코드
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        // OAuth 2.0 로그인 진행 시 키가 되는 필드값. 구글은 기본적으로 코드 지원(sub)
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        // OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
        // 조회한 OAuth2User가 있을 경우 update, 없을 경우 save
        User user = saveOrUpdate(attributes);
        // 세션에 사용자 정보를 저장하기 위한 Dto 클래스
        httpSession.setAttribute("user", new SessionUser(user));

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }

 

 

4) index.html 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>

<body>

    <h1>스프링 부트로 시작하는 웹 서비스</h1>

    <div class="col-md-12">
        <!-- 로그인 기능 영역 -->
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-dark">글 등록</a>
                <div th:if="${userName}">
                    Logged in as : <span id="user" th:text="${userName}"></span>
                    <a th:href="@{/logout}" class="btn btn-secondary active" role="button">Logout</a>
                </div>
                <div th:unless="${userName}">
                    <a th:href="@{/oauth2/authorization/google}" class="btn btn-primary active" role="button">Google Login</a>
                    <a th:href="@{/oauth2/authorization/naver}" class="btn btn-success active" role="button">Naver Login</a>
                </div>
            </div>
        </div>
        <br/>

        <!-- 목록 출력 영역 -->
        <table class="table table-horizontal table-bordered">
            <thead class="thead-strong">
            <tr>
                <th>게시글번호</th>
                <th>제목</th>
                <th>작성자</th>
                <th>최종수정일</th>
            </tr>
            </thead>
            <tbody id="tbody">
            <tr th:each="post : ${posts}">
                <td th:text="${post.id}">id</td>
                <td><a th:href="@{/posts/update/{id}(id = ${post.id})}" th:text="${post.title}">title</a></td>
                <td th:text="${post.author}">author</td>
                <td th:text="${post.modifiedDate}">modifiedDate</td>
            </tr>
            </tbody>
        </table>
    </div>

    <!-- footer 조각 -->
    <div th:replace="~{fragments/footer :: main-footer}"></div>

</body>
</html>

 

 

 

 


 

5. 카카오 로그인 

 

1) 카카오 로그인 설정

 

https://developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

 

2) application-oauth.properties

# kakao
spring.security.oauth2.client.registration.kakao.client-id=
spring.security.oauth2.client.registration.kakao.client-secret=
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,profile_image,account_email
spring.security.oauth2.client.registration.kakao.client-name=Kakao
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST

spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id

 

 

3) OAuthAttributes

package com.example.studyspringbootwebservice.config.auth.dto;

import com.example.studyspringbootwebservice.domain.user.Role;
import com.example.studyspringbootwebservice.domain.user.User;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;

import java.util.Map;

@Getter
@Slf4j
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);
        } else if ("kakao".equals(registrationId)) {
            return ofKakao(userNameAttributeName, attributes);
        }
        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {

        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");

        return OAuthAttributes.builder()
                .name((String) profile.get("nickname"))
                .email((String) kakaoAccount.get("email"))
                .picture((String) profile.get("thumbnail_image_url"))
                .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();
    }

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

    // OAuthAttributes에서 엔티티 생성하는 시점은 처음 가입할 때
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }

}

 

 

* 카카오의 attribute 구조

{
    id=12344656, 
    connected_at=2023-08-20T14:18:40Z, 
    properties=
    {
    	nickname=김준희, 
        profile_image=http://k.kakaocdn.net/~~.jpg, 
        thumbnail_image=http://k.kakaocdn.net/~~.jpg
    }, 
    kakao_account=
    {
    	profile_nickname_needs_agreement=false, 
        profile_image_needs_agreement=false, 
        profile=
        {
            nickname=김준희, 
            thumbnail_image_url=http://k.kakaocdn.net/~~.jpg, 
            profile_image_url=http://k.kakaocdn.net/~~.jpg, 
            is_default_image=false
        }, 
        has_email=true, 
        email_needs_agreement=false, 
        is_email_valid=true, 
        is_email_verified=true, 
        email=1234@naver.com
    }
}

 

4) index.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<!-- header 조각 -->
<div th:replace="~{fragments/header :: main-header}"></div>

<body>

    <h1>스프링 부트로 시작하는 웹 서비스</h1>

    <div class="col-md-12">
        <!-- 로그인 기능 영역 -->
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-dark">글 등록</a>
                <div th:if="${userName}">
                    Logged in as : <span id="user" th:text="${userName}"></span>
                    <a th:href="@{/logout}" class="btn btn-secondary active" role="button">Logout</a>
                </div>
                <div th:unless="${userName}">
                    <a th:href="@{/oauth2/authorization/google}" class="btn btn-primary active" role="button">Google Login</a>
                    <a th:href="@{/oauth2/authorization/naver}" class="btn btn-success active" role="button">Naver Login</a>
                    <a th:href="@{/oauth2/authorization/kakao}" class="btn btn-warning active" role="button">Kakao Login</a>
                </div>
            </div>
        </div>
        <br/>

        <!-- 목록 출력 영역 -->
        <table class="table table-horizontal table-bordered">
            <thead class="thead-strong">
            <tr>
                <th>게시글번호</th>
                <th>제목</th>
                <th>작성자</th>
                <th>최종수정일</th>
            </tr>
            </thead>
            <tbody id="tbody">
            <tr th:each="post : ${posts}">
                <td th:text="${post.id}">id</td>
                <td><a th:href="@{/posts/update/{id}(id = ${post.id})}" th:text="${post.title}">title</a></td>
                <td th:text="${post.author}">author</td>
                <td th:text="${post.modifiedDate}">modifiedDate</td>
            </tr>
            </tbody>
        </table>
    </div>

    <!-- footer 조각 -->
    <div th:replace="~{fragments/footer :: main-footer}"></div>

</body>
</html>