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 정보를 보여준다.
