김쥬르에 개발일지
Spring Boot OAuth2 구글 본문
구글 소셜 로그인을 구현해보도록 하겠습니다.
구글 클라우드 콘솔 https://cloud.google.com/cloud-console 로 접속 후
우측 상단에 "콘솔" 버튼을 클릭합니다.

이동 후 좌측 상단에 "프로젝트 선택" -> "새 프로젝트" 를 클릭합니다.
(저의 경우 이미 다른 프로젝트에 적용중이기에 "프로젝트 선택" 이 아닌 프로젝트 명으로 나옵니다)


프로젝트 이름은 자유롭게 사용하고 프로젝트 이름 설정 후 "만들기" 버튼을 클릭합니다.

좌측 상단에 햄버거 메뉴에서 "API 및 서비스" -> "사용자 인증 정보"
버튼을 눌러 사용자 인증을 관리하는 페이지로 이동합니다.

사용자 인증 정보를 만들려면 OAuth 동의 화면을 먼저 구성해야 합니다.
"동의 화면 구성" 버튼을 누르고 User Type을 외부용으로 선택 후 "만들기" 버튼을 클릭합니다.


그러면 앱 정보에 관한 화면이 나타납니다. 앱 이름은 자유롭게 기입하고
이메일 , 개발자 연락처를 입력 후 "저장 후 계속" 버튼을 클릭합니다.
(나머지 칸은 빈칸으로 둡니다)


범위 설정 화면에서는 "범위 추가 또는 삭제" 버튼을 클릭 후 openid 와 이메일 주소를 추가하고
"업데이트" 버튼을 클릭 한후 "저장 후 계속" 버튼을 누릅니다.


테스트 사용자 화면에서는 따로 추가하지 않고 "저장 후 계속" 버튼을 클릭합니다.

요약 화면이 나오면 "대시보드로 돌아가기" 버튼을 클릭합니다.

대시보드에서 "사용자 인증 정보" -> "사용자 인증 정보 만들기" -> "OAuth 클라이언트 ID"를 클릭합니다.

애플리케이션 유형 : 웹 애플리케이션
이름 : springbootOAuth
승인 리디렉션 URI : http://localhost:8080/login/oauth2/code/google
을 입력한 뒤 "만들기"를 클릭합니다.
그 후 나오는 클라이언트 ID 와 클라이언트 보안 비밀번호는
애플리케이션에서 사용하는 값이므로 따로 메모해둡니다.
생성된 OAuth 인증값은 추후에 리소스 오너의 정보에 접근할 때 사용합니다.
이제 스프링 부트에 관한 설정과 구현을 해보겠습니다.
OAuth 설정값을 스프링 부트 애플리케이션 설정 파일에서 사용하기 위해
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 파일에 코드를 추가합니다.
spring.profiles.include=oauth
인증값은 깃허브 같은 외부 사이트에 절대로 노출되면 안되기에
.gitignore 파일에 application-oauth.properties을 등록해줍니다.
OAuth2를 사용하기 위해 build.gradle 파일에 의존성을 추가합니다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
OAuth2 인증 플로우를 구현하며 쿠키를 사용할 일이 생기는데 그때마다 쿠리를 생성하고 삭제하는
로직을 추가하면 불편하므로 유틸리티로 사용할 쿠키 관리 클래스를 구현합니다.
util 패키지를 새로 만들고 CookieUtil.java 파일을 생성하고 코드를 작성합니다.
public class CookieUtil {
// 요청값 (이름 , 값 , 만료 기간) 을 바탕으로 쿠키 추가
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
// 쿠키의 이름을 입력받아 쿠키 삭제
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return;
}
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
// 객체를 직렬화해 쿠키의 값으로 변환
public static String serialize(Object obj) {
return Base64.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(obj));
}
// 쿠키를 역직렬화해ㅔ 객체로 변환
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(
SerializationUtils.deserialize(
Base64.getUrlDecoder().decode(cookie.getValue())
)
);
}
}
addCookie
요청값(이름,값,만료 기간)을 바탕으로 HTTP 응답에 쿠키를 추가합니다.
deleteCookie
쿠키 이름을 입력받아 쿠키를 삭제합니다.
실제로 삭제하는 방법은 없으므로 파라미터로 넘어온 키의 쿠키를
빈 값으로 바꾸고 만료 시간을 0으로 설정해 쿠키가 재생성 되자마자 만료 처리합니다.
serialize
객체를 직렬화해 쿠키의 값으로 들어갈 값으로 변환합니다.
deserialize
쿠키를 역직렬화 객체로 변환합니다.
사용자 정보를 조회해 users 테이블에 사용자 정보가 있다면 리소스 서버에서 제공해주는
이름을 업데이트하고 없다면 users 테이블에 새 사용자를 생성해 데이터베이스에 저장하는 서비스를 구현합니다.
domain 패키지의 User.java 파일에 사용자 이름과 OAuth 관련 키를 저장하는 코드를 추가합니다.
// 사용자 이름
@Column(name = "nickname", unique = true)
private String nickname;
// 생성자에 nickname 추가
@Builder
public User(String email, String password, String nickname) {
this.email = email;
this.password = password;
this.nickname = nickname;
}
// 사용자 이름 변경
public User update(String nickname) {
this.nickname = nickname;
return this;
}
config 패키지에 oauth 패키지를 만들고 OAuth2UserCustomService.java 파일을 생성한 다음
리소스 서버에서 보내주는 사용자 정보를 불러오는 메서드인 loadUser()를 통해
사용자를 조회하고 사용자가 users 테이블에 사용자 정보가 있다면 이름을 업데이트하고
없다면 saveOrUpdate() 메서드를 실행해 users 테이블에 회원 데이터를 추가합니다.
@RequiredArgsConstructor
@Service
public class OAuth2UserCustomService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User user = super.loadUser(userRequest); // ❶ 요청을 바탕으로 유저 정보를 담은 객체 반환
saveOrUpdate(user);
return user;
}
// ❷ 유저가 있으면 업데이트, 없으면 유저 생성
private User saveOrUpdate(OAuth2User oAuth2User) {
Map<String, Object> attributes = oAuth2User.getAttributes();
String email = (String) attributes.get("email");
String name = (String) attributes.get("name");
User user = userRepository.findByEmail(email)
.map(entity -> entity.update(name))
.orElse(User.builder()
.email(email)
.nickname(name)
.build());
return userRepository.save(user);
}
}
부모 클래스인 DefaultOAuth2UserService에서 제공하는 정보를 기반으로 유저 객체를 만들어주는
loadUser() 메서드를 사용해 사용자 객체를 불러옵니다.
사용자 객체는 식별자, 이름, 이메일, 프로필 사진 링크 등의 정보를 담고 있습니다.
saveOrUpdate() 메서드는 사용자가 user 테이블에 있으면 업데이트하고 없으면
사용자를 새로 생성해서 데이터베이스에 저장합니다.
OAuth2와 JWT를 함께 사용하려면 기존 스프링 시큐리티를 구현하며 작성한 설정이 아니라 다른 설정을 사용해야 합니다.
기존의 폼 로그인 방식을 사용하기 위해 구성했던 설정 파일인 webSecurityConfig.java 내용을 모두 주석처리합니다.
("ctrl + A" + "ctrl + /" 을 누르면 모두 주석 처리됩니다)
config 패키지에 WebOAuthSecurityConfig.java 파일을 생성하고 코드를 작성합니다.
@RequiredArgsConstructor
@Configuration
public class WebOAuthSecurityConfig {
private final OAuth2UserCustomService oAuth2UserCustomService;
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final UserService userService;
@Bean
public WebSecurityCustomizer configure() { // 스프링 시큐리티 기능 비활성화
return (web) -> web.ignoring()
.requestMatchers("/img/**", "/css/**", "/js/**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 토큰 방식으로 인증을 하기 때문에 기존에 사용하던 폼로그인, 세션 비활성화
http.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable);
http.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 헤더를 확인할 커스텀 필터 추가
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// 토큰 재발급 URL은 인증 없이 접근 가능하도록 설정. 나머지 API URL은 인증 필요
http.authorizeHttpRequests(request -> request
.requestMatchers("/api/token").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll());
http.oauth2Login(oauth2Login -> oauth2Login
.loginPage("/login")
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint
// Authorization 요청과 관련된 상태 저장
.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository()))
.successHandler(oAuth2SuccessHandler()) // 인증 성공시 실행할 핸들러
.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
.userService(oAuth2UserCustomService)));
http.logout(logout -> logout
.logoutSuccessUrl("/login"));
// /api로 시작하는 url인 경우 401 상태 코드를 반환하도록 예외 처리
http.exceptionHandling(exceptionHandling -> exceptionHandling
.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/api/**")));
return http.build();
}
@Bean
public AuthenticationSuccessHandler oAuth2SuccessHandler() {
return new OAuth2SuccessHandler(tokenProvider,
refreshTokenRepository,
oAuth2AuthorizationRequestBasedOnCookieRepository(),
userService
);
}
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter() {
return new TokenAuthenticationFilter(tokenProvider);
}
@Bean
public AuthorizationRequestRepository<OAuth2AuthorizationRequest> oAuth2AuthorizationRequestBasedOnCookieRepository() {
return new OAuth2AuthorizationRequestBasedOnCookieRepository();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
filterChain()
토큰 방식으로 인증을 하므로 기존 폼 로그인, 세션 기능을 비활성화합니다.
addFilterBefore()
헤더값을 확인할 커스텀 필터를 추가합니다.
authorizeRequest()
토큰 재발급 URL은 인증 없이 접근하도록 설정하고 나머지 API들은 모두 인증을 해야 접근하도록 설정합니다.
oauth2Login()
OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를
저장할 저장소를 설정합니다. 인증 성공시 실행할 핸들러도 설정합니다.
해당 클래스는 아직 구현하지 않았기에 에러가 발생합니다.
exceptionHandling()
/api로 시작하는 url인 경우 인증 실패 시 401 상태 코드, 즉 Unauthorized를 반환합니다.
OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과
관련된 상태를 저장할 저장소를 구현합니다.
config/oauth 패키지에 OAuth2AuthorizationRequestBasedOnCookieRepository.java 파일을 생성합니다.
권한 인증 흐름에서 클라이언트의 요청을 유지하는데 사용하는 AuthorizationRequestRepository 클래스를
구현해 쿠키를 사용해 OAuth의 정보를 가져오고 저장하는 로직을 작성합니다.
public class OAuth2AuthorizationRequestBasedOnCookieRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public final static String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
private final static int COOKIE_EXPIRE_SECONDS = 18000;
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
return CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
removeAuthorizationRequestCookies(request, response);
return;
}
CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
}
}
인증 성공 시 실행할 핸드러를 구현합니다. 해당 빈을 구현할 때 사용할 메서드를 만들기 위해
service 패키지의 UserService.java 파일을 수정합니다
BCryptPasswordEncoder를 삭제하고 BCryptPasswordEncoder 생성자를 사용해
직접 생성해서 패스워드를 암호화할 수 있게 코드를 수정하고 findByEmail() 메서드를 추가합니다.
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
public Long save(AddUserRequest dto) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
return userRepository.save(User.builder()
.email(dto.getEmail())
.password(encoder.encode(dto.getPassword()))
.build()).getId();
}
// 메서드 추가
public User findById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
public User findByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
}
findByEmail() 메서드는 이메일을 입력받아 users 테이블에서 유저를 찾고 없으면 예외를 발생합니다.
config/oauth 패키지에 OAuth2SuccessHandler.java 파일을 생성하고 코드를 작성합니다.
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14);
public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1);
public static final String REDIRECT_PATH = "/articles";
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
private final UserService userService;
// 리프레시 토큰 생성 -> 저장 -> 쿠키에 저장
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email"));
String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
saveRefreshToken(user.getId(), refreshToken);
addRefreshTokenToCookie(request, response, refreshToken);
// 엑세스 토큰 생성 -> 패스에 엑세스 토큰을 추가
String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
String targetUrl = getTargetUrl(accessToken);
// 인증 관련 설정값 , 쿠키 제거
clearAuthenticationAttributes(request, response);
// 리다이렉트
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
// 생성된 리프레시 토큰을 전달받아 데이터베이스에 저장
private void saveRefreshToken(Long userId, String newRefreshToken) {
RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
.map(entity -> entity.update(newRefreshToken))
.orElse(new RefreshToken(userId, newRefreshToken));
refreshTokenRepository.save(refreshToken);
}
// 생성된 리프레시 토큰을 쿠키에 저장
private void addRefreshTokenToCookie(HttpServletRequest request, HttpServletResponse response, String refreshToken) {
int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds();
CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME);
CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToken, cookieMaxAge);
}
// 인증 관련 설정값, 쿠키 제거
private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
}
// 엑세스 토큰을 패스에 추가
private String getTargetUrl(String token) {
return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
.queryParam("token", token)
.build()
.toUriString();
}
}
스프링 시큐리티의 기본 로직에서는 별도의 authenticationSuccessHandler를 지정하지 않으면
로그인 성공 이후 SimpleUrlAuthenticationSuccessHandler를 사용합니다.
일반적인 로직은 동일하게 사용하고 , 토큰과 관련된 작업만 추가로 처리하기 위해
SimpleUrlAuthenticationSuccessHandler을 상속받은 뒤에
onAuthenticationSuccess()메서드를 오버라이드합니다.
리프레시 토큰 생성, 저장, 쿠키에 저장
토큰 제공자를 사용해 리프레시 토큰을 만든 뒤에 saveRefreshToken() 메서드를 호출해
해당 리프레시 토큰을 데이터베이스에 유저 아이디와 함께 저장합니다.
그 이후에는 클라이언트에서 액세스 토큰이 만료되면 재발급 요청하도록 addRefreshTokenToCookie()
메서드를 호출해 쿠키에 리프레시 토큰을 저장합니다.
엑세스 토큰 생성, 패스에 엑세스 토큰 추가
토큰 제공자를 사용해 엑세스 토큰을 만든 뒤에 쿠키에서
리다이렉트 경로가 담긴 값을 가져와 쿼리 파라미터에 엑세스 토큰을 추가합니다.
인증 관련 설정값, 쿠키 제거
인증 프로세스를 진행하면서 세션과 쿠키에 임시로 저장해둔 인증 관련 데이터를 제거합니다.
기본적으로 제공하는 메서드인 clearAuthenticationAttributes() 은 그대로 호출하고
removeAuthorizationRequestCookies()를 추가로 호출해 OAuth 인증을 위해 저장된 정보도 삭제합니다.
OAuth를 위한 로직이 모두 완료되었으니 글에 글쓴이를 추가하는 작업을 진행합니다.
domain 패키지의 Article.java 파일을 연 다음 author 변수를 추가합니다.
이후에 빌더 패턴에서도 author를 추가해 객체를 생성할 때 글쓴이를 입력받을 수 있게 변경합니다.
@Column(name = "author" , nullable = false)
private String author;
@Builder
public Article(String author, String title , String content){
this.author = author;
this.title = title;
this.content = content;
}
기존 글을 작성하는 API에서 작성자를 추가로 저장하기 위해 DTO 패키지의
AddArticleRequest.java 파일을 열고 toEntity() 메서드를 수정해 author 값도 추가 저장하도록 변경합니다.
public Article toEntity(String author) {
return Article.builder()
.title(title)
.content(content)
.author(author)
.build();
}
}
service 패키지의 BlogService.java 파일을 연 다음 save() 메서드에서 유저 이름을
추가로 입력받고 toEntity()의 인수로 전달받은 유저 이름을 반환하도록 코드를 수정합니다.
public Article save(AddArticleRequest request, String userName) {
return blogRepository.save(request.toEntity(userName));
}
controller 패키지의 BlogApiController.java 파일을 연 다음 현재 인증 정보를 가져오는
principal 객체를 파라미터로 추가합니다. 인증 객체에서 유저 이름을 가져온 뒤 save() 메서드로 넘겨줍니다.
@PostMapping("/api/articles")
public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request, Principal principal){
Article savedArticle = blogService.save(request, principal.getName());
return ResponseEntity.status(HttpStatus.CREATED)
.body(savedArticle);
}
글 상세 페이지에서도 글쓴이의 정보가 보여야 하므로
dto 패키지의 ArticleViewResponse.java 파일을 수정합니다.
author 필드를 추가합니다.
private Long id;
private String title;
private String content;
private LocalDateTime createdAt;
private String author;
public ArticleViewResponse(Article article) {
this.id = article.getId();
this.title = article.getTitle();
this.content = article.getContent();
this.createdAt = article.getCreatedAt();
this.author = article.getAuthor();
}
}
새로 추가한 author 컬럼을 추가하기 위해 기존에 있던 데이터를 지우고 다시 생성해줍니다.
insert into article (title, content, author, created_at, update_at)
values ('제목1', '내용1', "user1", Now(),Now());
insert into article (title, content, author, created_at, update_at)
values ('제목2', '내용2', "user2", Now(),Now());
insert into article (title, content, author, created_at, update_at)
values ('제목3', '내용3', "user3", Now(),Now());
뷰에서 글쓴이의 정보를 알 수 있게 뷰를 수정합니다.
article.html 파일을 열어 글쓴이의 정보를 가져올수 있게 코드를 수정합니다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>
<div class="container mt-5">
<div class="row">
<div class="col-lg-8">
<article>
<input type="hidden" id="article-id" th:value="${article.id}">
<header class="mb-4">
<h1 class="fw-bolder mb-1" th:text="${article.title}"></h1>
<!-- 수정된 부분 -->
<div class="text-muted fst-italic mb-2" th:text="|Posted on ${#temporals.format(article.createdAt, 'yyyy-MM-dd HH:mm')} By ${article.author}|"></div>
<!-- ------ -->
</header>
<section class="mb-5">
<p class="fs-5 mb-4" th:text="${article.content}"></p>
</section>
<button type="button" id="modify-btn"
th:onclick="|location.href='@{/new-article?id={articleId}(articleId=${article.id})}'|"
class="btn btn-primary btn-sm">수정</button>
<button type="button" id="delete-btn"
class="btn btn-secondary btn-sm">삭제</button>
</article>
</div>
</div>
</div>
<script src="/js/article.js"></script>
</body>
모든 비즈니스 로직이 만들어졌으니 OAuth의 뷰를 구성합니다.
controller 패키지의 UserViewController.java 파일을 연 다음
login() 메서드의 뷰를 oauthLogin으로 변경합니다.
@GetMapping("/login")
public String login() {
return "oauthlogin";
}
로그인 화면에서 사용할 이미지를 첨부해둔 이미지를 다운받아
/resoureces/static/img 디렉터리를 만들고 그곳에 붙여넣기합니다.
(이미지 이름이 google.png가 아닌경우 google.png로 변경합니다)

이제 이 이미지를 활용하여 로그인 화면에서 OAuth 연결 버튼을 생성합니다.
templates 디렉터리에 oauthLogin.html 파일을 생성하고 코드를 작성합니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">
<style>
.gradient-custom {
background: #6a11cb;
background: -webkit-linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1));
background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))
}
</style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
<div class="container-fluid row justify-content-center align-content-center">
<div class="card bg-dark" style="border-radius: 1rem;">
<div class="card-body p-5 text-center">
<h2 class="text-white">LOGIN</h2>
<p class="text-white-50 mt-2 mb-5">서비스 사용을 위해 로그인을 해주세요!</p>
<div class = "mb-2">
<a href="/oauth2/authorization/google">
<img src="js/img/google.png">
</a>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
HTML 파일과 연결할 자바스크립트 파일을 만듭니다.
resources/js 디렉터리에 token.js 파일을 만들고 코드를 작성합니다.
이 코드는 파라미터로 받은 토큰이 있다면 토큰을 로컬 스토리지에 저장합니다.
const token = searchParam('token')
if (token) {
localStorage.setItem("access_token", token)
}
function searchParam(key) {
return new URLSearchParams(location.search).get(key);
}
articleList.html에 token.js를 가져올 수 있도록 코드를 추가합니다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글 목록</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>
<div class="container">
<button type="button" id="create-btn"
th:onclick="|location.href='@{/new-article}'|"
class="btn btn-secondary btn-sm mb-3">글 등록</button>
<div class="row-6" th:each="item : ${articles}">
<div class="card">
<div class="card-header" th:text="${item.id}">
</div>
<div class="card-body">
<h5 class="card-title" th:text="${item.title}"></h5>
<p class="card-text" th:text="${item.content}"></p>
<a th:href="@{/articles/{id}(id=${item.id})}" class="btn btn-primary">보러가기</a>
</div>
</div>
<br>
</div>
<button type="button" class="btn btn-secondary" onclick="location.href='/logout'">로그아웃</button>
</div>
<script src="/js/article.js"></script>
<!-- token.js 추가 -->
<script src="/js/token.js"></script>
</body>
resources/js 패키지에 있는 article.js 파일을 열어 기존 createButton 관련 코드를 추가합니다.
// 삭제 기능
const deleteButton = document.getElementById('delete-btn');
if (deleteButton) {
deleteButton.addEventListener('click', event => {
let id = document.getElementById('article-id').value;
function success() {
alert('삭제가 완료되었습니다.');
location.replace('/articles');
}
function fail() {
alert('삭제 실패했습니다.');
location.replace('/articles');
}
httpRequest('DELETE',`/api/articles/${id}`, null, success, fail);
});
}
// 수정 기능
const modifyButton = document.getElementById('modify-btn');
if (modifyButton) {
modifyButton.addEventListener('click', event => {
let params = new URLSearchParams(location.search);
let id = params.get('id');
body = JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
})
function success() {
alert('수정 완료되었습니다.');
location.replace(`/articles/${id}`);
}
function fail() {
alert('수정 실패했습니다.');
location.replace(`/articles/${id}`);
}
httpRequest('PUT',`/api/articles/${id}`, body, success, fail);
});
}
// 생성 기능
const createButton = document.getElementById('create-btn');
if (createButton) {
// 등록 버튼을 클릭하면 /api/articles로 요청을 보낸다
createButton.addEventListener('click', event => {
body = JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
});
function success() {
alert('등록 완료되었습니다.');
location.replace('/articles');
};
function fail() {
alert('등록 실패했습니다.');
location.replace('/articles');
};
httpRequest('POST','/api/articles', body, success, fail)
});
}
// 쿠키를 가져오는 함수
function getCookie(key) {
var result = null;
var cookie = document.cookie.split(';');
cookie.some(function (item) {
item = item.replace(' ', '');
var dic = item.split('=');
if (key === dic[0]) {
result = dic[1];
return true;
}
});
return result;
}
// HTTP 요청을 보내는 함수
function httpRequest(method, url, body, success, fail) {
fetch(url, {
method: method,
headers: { // 로컬 스토리지에서 액세스 토큰 값을 가져와 헤더에 추가
Authorization: 'Bearer ' + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
},
body: body,
}).then(response => {
if (response.status === 200 || response.status === 201) {
return success();
}
const refresh_token = getCookie('refresh_token');
if (response.status === 401 && refresh_token) {
fetch('/api/token', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
},
body: JSON.stringify({
refreshToken: getCookie('refresh_token'),
}),
})
.then(res => {
if (res.ok) {
return res.json();
}
})
.then(result => { // 재발급이 성공하면 로컬 스토리지값을 새로운 액세스 토큰으로 교체
localStorage.setItem('access_token', result.accessToken);
httpRequest(method, url, body, success, fail);
})
.catch(error => fail());
} else {
return fail();
}
});
}
이 코드는 POST 요청을 보낼 때 엑세스 토큰도 함께 보냅니다.
만약 응답에 권한이 없다는 에러 코드가 발생하면 리프레시 토큰과 함께
새로운 엑세스 토큰을 요청하고 , 전달받은 엑세스 토큰으로 다시 API를 요청합니다.
삭제,수정 기능 또한 httpRequest() 함수를 사용하도록 코드를 수정합니다.
이제 글을 수정하거나 삭제할 때 요청 헤더에 토큰을 전달하므로 사용자 자신이
작성한 글인지 검증할 수 있습니다. 본인 글이 아닌데 수정 , 삭제를 시도하는 경우
예외를 발생시키도록 코드를 수정합니다. BlogService.java 파일을 열어 코드를 수정합니다.
package com.example.springboot.service;
import com.example.springboot.domain.Article;
import com.example.springboot.dto.AddArticleRequest;
import com.example.springboot.dto.UpdateArticleRequest;
import com.example.springboot.repository.BlogRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.List;
@RequiredArgsConstructor // final이 붙거나 @NotNull이 붙은 필드의 생성자 추가
@Service // 빈으로 등록
public class BlogService {
private final BlogRepository blogRepository;
// 블로그 글 추가 메서드
public Article save(AddArticleRequest request, String userName) {
return blogRepository.save(request.toEntity(userName));
}
public List<Article> findAll() {
return blogRepository.findAll();
}
public Article findById(long id) {
return blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found : " + id));
}
public void delete(long id) {
Article article = blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found : " + id));
authorizeArticleAuthor(article);
blogRepository.delete(article);
}
@Transactional // 트랜잭션 메서드
public Article update(long id, UpdateArticleRequest request) {
Article article = blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found : " + id));
authorizeArticleAuthor(article);
article.update(request.getTitle(), request.getContent());
return article;
}
// 게시글을 작성한 유저인지 확인
private static void authorizeArticleAuthor(Article article) {
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
if (!article.getAuthor().equals(userName)) {
throw new IllegalArgumentException("not authorized");
}
}
}
수정, 삭제 메서드는 작업을 수행하기 전 authorizeArticleAuthor() 메서드를 실행해
현재 인증 객체에 담겨 있는 사용자의 정보와 글을 작성한 사용자의 정보를 비교합니다.
만약 서로 다르면 예외를 발생시켜 작업을 수행하지 않습니다.
이제 스프링부트 서버를 실행한 다음
http://localhost:8080/login 에 접속하고 "구글로 로그인하기" 버튼을 클릭합니다.

버튼을 클릭하면 구글 로그인 페이지가 나타나며 google 아이디를 통해 로그인해봅시다.

로그인이 완료되면 /articles로 리다이렉트되며 쿼리 파라미터에 token, 엑세스 토큰을 요청 헤더로 전달합니다.
그 뒤에 키보드에 "F12"버튼을 누르고 [Application] -> [Local Storage] 를 클릭하면 스프링 부트 애프리케이션으로부터
전달받은 액세스 토큰을 저장한다는 것을 확인할 수 있습니다.

리프레시 토큰도 잘 구현됐는지 확인해보겠습니다.
[Cookies]를 누르면 리프레시 토큰도 잘 저장된걸 확인할수 있습니다.

/new-article로 이동해 글을 등록해봅시다.
글 등록 시 엑세스 토큰이 유효하므로 인증 필터를 통과하고 글도 잘 등록되는걸 확인할 수 있습니다.

만약 액세스 토큰이 만료되거나 삭제되면 어떤 API 요청 흐름이 발생하는지 알기 위해
F12 버튼 [Application] -> [Local Storage]에 들어간 후 access_token를 우클릭하고
[Delete]를 눌러 값을 삭제해봅시다.

다시 /new-article로 이동한 뒤 글 등록을 시도해봅시다

액세스 토큰이 유효하지 않지만 리프레시 토큰이 있으므로 /token API를 호출해 새 엑세스 토큰을
발급받아 인증을 다시 요청해 인증 필터를 통과해 글이 잘 등록되는걸 확인할 수 있습니다.

이번에는 내가 작성하지 않은 글을 수정하거나 삭제하면 어떻게 되는지 확인해보겠습니다.
내가 작성한 글이 아닌 글을 삭제하려고 하면 삭제 실패 메시지가 나오고 삭제가 되지 않습니다.(수정도 똑같음)
다음 포스팅엔 AWS를 이용한 배포를 해보겠습니다.