본문 바로가기
코딩/Spring Boot

Spring Boot와 OAuth2 연결(with Google)

by skyoon 2025. 3. 18.

Git Code 버전 : 0.0.8-SNAPSHOT

 

이전 글에서 Google에서 OAuth2(https://skyoon.tistory.com/2689636) 환경 설정을 하고 Spring Boot의 Security와 연동하는 내용을 정리해 보겠다. 

  • OAuth2를 기준으로 코드를 생성하기 때문에 작성 중에는 Code상에 에러가 많음 모든 코드 작성 후 실행 권장

 

1. build.gradle에 dependencies를 추가한다.

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

 

2. Google에서 활성화한 OAuth2 접속을 위한 설정파일을 application.yml에 추가하는데, 향후 kakao, naver 등의 OAuth2 접속정보 설정을 하게 될 경우를 위해 applicaiton-oauth2.yml 파일을 신규로 추가해 준다.

  • registration, Provider아래 OAuth2 API를 제공하는 업체별로 업체명으로 구분 지어 등록하며, client-id, client-secret의 경우는 접속을 위한 인증 정보이므로 업체에서 API 신청 시 생성되며 해당 정보를 입력해 준다.
spring:
    security:
        oauth2:
            client:
                registration:
                    google:
                        client-id: #google client id
                        client-secret: #google client password
                        scope: profile,email
                        redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
                        authorization-grant-type: authorization_code
                provider:
                    google:
                        authorization-uri: https://accounts.google.com/o/oauth2/auth
                        token-uri: https://oauth2.googleapis.com/token
                        user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
  • 이렇게 추가한 applicaiton-oauth2.yml 파일은 application.yml에 아래처럼 추가해 주면 application.yml 파일도 기능별로 분류 관리가 가능하다.
spring:
	{ 중략 }
    profiles:
        include: oauth2

 

3. OAuth2를 통해 사이트 접속에 User의 Login/out을 구현하기 위해 Spring의 Security 설정을 아래와 같이 SecurityConfig.java파일을 src/main/java/io/personal/stock/config 아래 생성해 준다.

  • SecurityConfig.java
    • SecurityConfig를 통해 login이 안되어도 접속이 가능한 페이지, Login, Logout시 페이지와 해당 액션시 동작을 정의한다.
더보기
package io.personal.stock.config;

import org.springframework.beans.factory.annotation.Autowired;
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;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

import io.personal.stock.service.impl.OAuth2UserServiceImpl;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private OAuth2UserServiceImpl oAuth2UserService;

    @Bean
    public SecurityFilterChain SecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // CSRF 토큰 저장소 설정
                )
                .authorizeHttpRequests(requests -> requests
                        .requestMatchers("/", "/login", "/oauth2/**").permitAll()
                        .requestMatchers("/assets/**", "/css/**", "/js/**").permitAll()
                        .anyRequest().authenticated())
                .oauth2Login(login -> login
                        .loginPage("/login") // 사용자 정의 로그인 페이지
                        .defaultSuccessUrl("/", true) // 로그인 성공 시 리다이렉트
                        .failureUrl("/login?error=true") // 로그인 실패 시 리다이렉트
                        .userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService))
                        .permitAll())
                .logout(logout -> logout
                        .logoutSuccessUrl("/") // 로그아웃 성공 후 리다이렉트할 URL
                        .invalidateHttpSession(true) // 세션 무효화
                        .clearAuthentication(true) // 인증 정보 삭제
                        .permitAll()); // 로그아웃을 누구나 허용

        return http.build();
    }
}

 

4. OAuth2 사용자 인증 처리를 위해 SecurityConfig.java에서는 DefaultOAuth2UserService를 상속받아, 사용자 인증을 처리하는 OAuth2UserServiceImpl을 구현한다.

  • OAuth2UserServiceImpl.java
더보기
package io.personal.stock.service.impl;

import java.util.Optional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import io.personal.stock.entity.Member;
import io.personal.stock.repo.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Log4j2
@Service
@RequiredArgsConstructor
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {

    @Autowired
    private final MemberRepository memberRepository;

    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String email = oAuth2User.getAttribute("email");

        log.info("E-Mail : {}, \t Registration ID : {}", email, registrationId);

        Optional<Member> existMember = memberRepository.findByEmail(email);

        existMember.ifPresentOrElse(member -> {
            // Login시 계정 정보 Update하도록 하는 샘플 Code
            // 필요하면 기능 추가
            memberRepository.save(member);
        }, () -> {
            // Login시 계정 정보 없으면 추가하는 코드
            Member member = new Member();

            member.setName(oAuth2User.getAttribute("name"));
            member.setEmail(email);
            member.setOauth2Vender(registrationId);

            memberRepository.save(member);
        });

        return oAuth2User;
    }
}

 

5. OAuth2UserServiceImple에서 Login시 Login인 된 User의 정보를 DB에 저장하고 관리하기 위해 DB에 user_info Table을 생성하고 관리하기 위해 JPA를 사용하여 관리하기 위한 클래스들을 생성해 준다.(파일들의 위치는 기존 JPA 구조에 맞게 위치시킴)

  • Member.java
더보기
package io.personal.stock.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@Entity(name = "user_info")
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String passwd;

    private String name;

    private String email;

    private String oauth2Vender;

    int failLoginCnt;

}
  • MemberRepository.java
더보기
package io.personal.stock.repo;

import java.util.Optional;

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

import io.personal.stock.entity.Member;

@Repository
public interface MemberRepository extends JpaRepository<Member, Object> {
    public Optional<Member> findByIdAndPasswd(Long id, String passwd);

    public Optional<Member> findByEmail(String email);
}
  • MemberService.java
더보기
package io.personal.stock.service;

import java.util.List;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.ui.Model;

import io.personal.stock.entity.Member;
import jakarta.servlet.http.HttpServletRequest;

public interface MemberService {

    public void loginCheckAndInsertModel(@AuthenticationPrincipal OAuth2User principal, HttpServletRequest request,
            Model model);

    public List<Member> getAllUsers();
}
  • MemberServiceImpl.java
더보기
package io.personal.stock.service.impl;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;

import io.personal.stock.entity.Member;
import io.personal.stock.repo.MemberRepository;
import io.personal.stock.service.MemberService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Log4j2
@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    @Autowired
    MemberRepository memberRepo;

    public void loginCheckAndInsertModel(@AuthenticationPrincipal OAuth2User principal, HttpServletRequest request,
            Model model) {

        if (principal != null) {
            CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
            model.addAttribute("csrfToken", csrfToken.getToken());
            model.addAttribute("csrfParameterName", csrfToken.getParameterName());

            String name = principal.getAttribute("name"); // 사용자 이름 가져오기
            model.addAttribute("name", name);
            model.addAttribute("isLoggedIn", true); // 로그인 상태
        } else {
            model.addAttribute("isLoggedIn", false); // 로그인 상태
        }
    }

    public List<Member> getAllUsers() {
        return memberRepo.findAll();
    }
}

 

6. 루트 경로"/"에 접속하는 기존 index() 함수를 아래와 같이 수정한다.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;

import org.springframework.web.bind.annotation.PostMapping;

import io.personal.stock.service.MemberService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

public class LoginController {
    @Autowired
    MemberService memberService;

    // 기존 코드
    public String index() {
        return "index";
    }

    // 수정 코드
    @GetMapping("/")
    public String index(@AuthenticationPrincipal OAuth2User principal, HttpServletRequest request, Model model) {

        memberService.loginCheckAndInsertModel(principal, request, model);

        return "index";
    }
    
}

 

7. login, logout 동작을 처리할 login, logout 함수도 LoginController.java에 추가한다.

    @GetMapping("/login")
    public String login(HttpServletRequest request, Model model) {

        log.debug(String.format("Call Log In Page, CsrfToken class getName : %s", CsrfToken.class.getName()));

        CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
        model.addAttribute("csrfToken", csrfToken.getToken());
        model.addAttribute("csrfParameterName", csrfToken.getParameterName());
        log.debug(String.format("CSRF Token : %s", csrfToken.getToken().toString()));
        log.debug(String.format("csrfParameterName : %s", csrfToken.getParameterName()));

        return "login";
    }

    @PostMapping("/logout")
    public String logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {

        log.debug("Call Log Out Page");
        if (authentication != null) {
            new SecurityContextLogoutHandler().logout(request, response, authentication);
        }

        return "redirect:/oauth2/logout";
    }

    @PostMapping("/oauth2/logout")
    public String oauth2Logout() {
        // Google 로그아웃 URL
        String googleLogoutUrl = "https://accounts.google.com/Logout"; // Google 로그아웃 URL
        return "redirect:" + googleLogoutUrl;
    }

 

8. 로그인을 통해 인증 절차가 추가되었기 때문에 web page에 접속 시 해당 page를 return 하는 Controller 함수들에 Login 정보를 넘겨서 Web Page에서 로그인 상태를 유지시킨다.

  • ConfigController.java
더보기
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;

import io.personal.stock.service.MemberService;
import jakarta.servlet.http.HttpServletRequest;

public class ConfigController {

    @Autowired
    MemberService memberService;
    
    // 기존에는 아래와 같았음
    public String conf(Model model) {
    
    @GetMapping(value = "/conf")
    public String conf(@AuthenticationPrincipal OAuth2User principal, HttpServletRequest request, Model model) {

        memberService.loginCheckAndInsertModel(principal, request, model);
        
    }
}
  • StockController.java
더보기
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;

import io.personal.stock.service.MemberService;
import jakarta.servlet.http.HttpServletRequest;

public class StockController {

    @Autowired
    MemberService memberService;
    
    // 기존에는 아래와 같았음
    public String stockMain(Model model)
    
    @GetMapping(value = "/stock")
    public String stockMain(@AuthenticationPrincipal OAuth2User principal, HttpServletRequest request, Model model) {

        memberService.loginCheckAndInsertModel(principal, request, model);
    }
}
  • InfoController.java
    • 사용자 정보를 조회하는 페이지를 추가한다.
더보기
import java.util.List;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.ui.Model;
import io.personal.stock.entity.Member;
import io.personal.stock.service.MemberService;
import jakarta.servlet.http.HttpServletRequest;


public class InfoController {

    @Autowired
    MemberService memberService;
    
    // 기존 코드는 아래와 같았음
    public String info()
    
    @GetMapping(value = "/info")
    public String info(@AuthenticationPrincipal OAuth2User principal, HttpServletRequest request, Model model) {

        memberService.loginCheckAndInsertModel(principal, request, model);
    }
    
    
    // 사용자 정보 조회 페이지 함수 추가
    @GetMapping(value = "/users")
    public String user(@AuthenticationPrincipal OAuth2User principal, HttpServletRequest request, Model model) {

        List<Member> members = memberService.getAllUsers();

        model.addAttribute("members", members);

        memberService.loginCheckAndInsertModel(principal, request, model);

        return "users";
    }
}

 

9. Login을 위한 login.html을 만든다.

더보기
<!DOCTYPE html>
<html lang="ko">
    <head th:replace="~{layouts/header :: headerFragment(~{::title})}">
        <title>Config</title>
    </head>
    <body>
        <div id="layoutAuthentication">
            <div id="layoutAuthentication_content">
                <main>
                    <div class="container">
                        <div class="row justify-content-center">
                            <div class="col-md-4">
                                <div class="card shadow-lg border-0 rounded-lg mt-5">
                                    <div class="card-header">
                                        <h3 class="text-center font-weight-light my-4">Login</h3>
                                    </div>
                                    <div class="card-body">
                                        <a th:href="@{/oauth2/authorization/google}">
                                            <img src="assets/img/web_neutral_rd_SI.svg" />
                                        </a>
                                        <div th:if="${param.error}" class="alert alert-danger mt-3">
                                            Invalid username or password.
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </main>
            </div>
            <div id="layoutAuthentication_footer">
                <footer th:replace="~{layouts/footer :: footerFragment}"></footer>
            </div>
        </div>
        <th:block th:replace="~{layouts/scripts :: scriptFragment}"></th:block>
    </body>
</html>

 

10. 로그인/로그아웃을 위한 UI를 top.html에서 수정한다.

  • top에서 로그인을 하면 csrfToken을 추가해서 모든 페이지에서 포함할 수 있도록 해준다.
더보기
<!-- 기존 코드 -->
<li><a class="dropdown-item" href="/login">Login</a></li>

<!-- 신규 코드 -->
<li th:if="${!isLoggedIn}"><a class="dropdown-item" href="/login">Login</a></li>



<!-- 기존 코드 -->
<form action="/logout" method="post" style="display: inline">
    <input type="hidden"/>
    <button type="submit" class="nav-link btn btn-link">Logout</button>
</form>

<!-- 신규 코드 -->
<form action="/logout" method="post" style="display: inline">
    <input type="hidden" th:name="${csrfParameterName}" th:value="${csrfToken}" />
    <button type="submit" class="nav-link btn btn-link">Logout</button>
</form>

 

11. 인증을 위해 csrf 정보를 header에 추가해서 모든 html페이지에서 포함할 수 있도록 해준다.

<meta name="_csrf_header" th:content="${_csrf.headerName}" />

 

12. conf페이지에서는 환경 설정등을 위해 추가 수정 삭제가 있기 때문에 csrf 정보가 있어야 처리가 된다. 기존에 코드에서 csrfToken, csrfHeader를 포함해 준다.(Form을 사용하는 곳은 아래와 같은 코드를 넣어준다. 107 Line)

  • HTML상 수정 내용
<!-- CSRF 토큰을 HTML FORM Tag안 에 삽입 -->
<s:csrfInput />
  • JavaScript상 수정 내용
function saveConfig() {
    {중략}
    const csrfToken = $('input[name="_csrf"]').val();
    const csrfHeader = $('meta[name="_csrf_header"]').attr("content");
    
    $.ajax({
        type: "POST",
        url: "/conf/add",
        contentType: "application/json",
        data: JSON.stringify(configData),
        // 추가 코드
        beforeSend: function (xhr) {
            xhr.setRequestHeader(csrfHeader, csrfToken); // CSRF 토큰을 요청 헤더에 추가
        },
        success: function (response)
    });
}

{중략} 

function deleteConfig(id, row) {
    const csrfToken = $('input[name="_csrf"]').val();
    const csrfHeader = $('meta[name="_csrf_header"]').attr("content");

    $.ajax({
        url: "/conf/delete/" + id,
        type: "DELETE",
        beforeSend: function (xhr) {
            xhr.setRequestHeader(csrfHeader, csrfToken);
        },
        success: function (response)
    });
}

 

13. 사용자 정보 조회를 위한 users.html 파일을 추가해 준다.

더보기
<!DOCTYPE html>
<html lang="ko">
    <head th:replace="~{layouts/header :: headerFragment(~{::title})}">
        <title>Users</title>
    </head>
    <body class="sb-nav-fixed">
        <nav th:replace="~{layouts/top :: top-nav}"></nav>
        <div id="layoutSidenav">
            <!-- Left Side Menu-->
            <div th:replace="~{layouts/left-side :: side-nav}"></div>

            <div id="layoutSidenav_content">
                <main>
                    <div class="container-fluid px-4">
                        <h1 class="mt-4">사용자 목록</h1>
                        <ol class="breadcrumb mb-4">
                            <li class="breadcrumb-item active">사용자 목록</li>
                        </ol>
                        <div class="row">
                            <div class="md-12"></div>
                        </div>

                        <div class="row mt-5">
                            <div class="md-12">
                                <div class="card-body">
                                    <table id="itemTable" class="datatable-table">
                                        <thead>
                                            <tr>
                                                <th>ID</th>
                                                <th>Name</th>
                                                <th>E-Mail</th>
                                                <th>OAuth2 Vender</th>
                                            </tr>
                                        </thead>
                                        <tbody>
                                            <tr th:each="member: ${members}">
                                                <td th:text="${member.id}">ID</td>
                                                <td th:text="${member.name}">Name</td>
                                                <td th:text="${member.email}">E-Mail</td>
                                                <td th:text="${member.oauth2Vender}">OAuth2 Vender</td>
                                            </tr>
                                        </tbody>
                                    </table>
                                </div>
                            </div>
                        </div>
                    </div>
                </main>
                <footer th:replace="~{layouts/footer :: footerFragment}"></footer>
            </div>
        </div>

        <th:block th:replace="~{layouts/scripts :: scriptFragment}"></th:block>
        <script type="text/javascript">
            $(document).ready(function () {});
        </script>
    </body>
</html>

 

코드를 실행하고 사이트에 접속하고 main page 이외의 링크를 클릭하면 login page로 리다이렉션이 되며 로그인을 요구한다.

로그인을 수행하고 Config > User로 가면 로그인된 사용자 이름 E-Mail 정보를 보여준다.