지난 글에서 이어집니다!
✨ OAuth2로 로그인/로그아웃 구현하기 (1)
먼저 OAuth2를 사용하기 위해 의존성을 추가해 준다.
dependencies {
(생략)
// OAuth2를 사용하기 위한 스타터 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
OAuth2 인증을 구현하며 쿠키
를 사용할 일이 생기는데 그때마다 쿠키를 삭제하고 로직을 추가하면 불편하기 때문에 유틸리티로 사용할 쿠키 관리 클래스를 미리 구현한다.
package me.ansoohyeon.springbootdeveloper.util;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.util.SerializationUtils;
import java.util.Base64;
public class CookieUtil {
// 요청값(이름, 값, 만료 기간)을 바탕으로 HTTP 응답에 쿠키 추가
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;
}
// 실제로 삭제하는 방법은 없으므로 파라미터로 넘어온 키의 쿠키를 빈 값으로 바꾸고
// 만료 시간을 0으로 설정해 쿠키가 재생성 되자마자 만료 처리한다.
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())
)
);
}
}
사용자 이름과 OAuth 관련 키를 저장하는 코드를 작성한다.
package me.ansoohyeon.springbootdeveloper.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "password")
private String password;
// 사용자 이름
@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;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("user"));
}
@Override
public String getUsername() {
return email;
}
@Override
public String getPassword() {
return password;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
리소스 서버에서 보내 주는 사용자 정보를 불러오는 loadUser()
메소드를 통해 사용자를 조회하고, users 테이블에 사용자 정보가 있다면 이름을 업데이트하고 없다면 saveOrUpdate()
메소드를 실행해 users 테이블에 회원 데이터를 추가한다.
package me.ansoohyeon.springbootdeveloper.config.oauth;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.domain.User;
import me.ansoohyeon.springbootdeveloper.repository.UserRepository;
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 java.util.Map;
@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);
}
}
OAuth2와 JWT를 함께 사용하려면 기존 스프링 시큐리티를 구현하며 작성한 설정이 아니라 다른 설정을 사용해야 한다. 따라서 기존의 폼 로그인 방식을 사용하기 위해 구성했던 설정 파일인 WebSecurityConfig
내용을 모두 주석 처리한다.
기존 작성했던 내용을 모두 주석 처리!
그리고 아래 파일을 생성하고 코드를 작성한다.
package me.ansoohyeon.springbootdeveloper.config;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.config.jwt.TokenProvider;
import me.ansoohyeon.springbootdeveloper.config.oauth.OAuth2AuthorizationRequestBasedOnCookieRepository;
import me.ansoohyeon.springbootdeveloper.config.oauth.OAuth2SuccessHandler;
import me.ansoohyeon.springbootdeveloper.config.oauth.OAuth2UserCustomService;
import me.ansoohyeon.springbootdeveloper.repository.RefreshTokenRepository;
import me.ansoohyeon.springbootdeveloper.service.UserService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import static org.springframework.boot.autoconfigure.security.servlet.PathRequest.toH2Console;
@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(toH2Console())
.requestMatchers("/img/**", "/css/**", "/js/**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 토큰 방식으로 인증을 하기 때문에 기존에 사용하던 폼 로그인, 세션 비활성화
http.csrf().disable()
.httpBasic().disable()
.formLogin().disable()
.logout().disable();
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 헤더를 확인할 커스텀 필터 추가
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// 토큰 재발급 URL은 인증 없이 접근 가능하도록 설정. 나머지 API는 URL은 인증 필요
http.authorizeRequests()
.requestMatchers("/api/token").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll();
http.oauth2Login()
.loginPage("/login")
.authorizationEndpoint()
// Authorization 요청과 관련된 상태 저장
.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
.and()
.successHandler(oAuth2SuccessHandler()) // 인증 성공 시 실행할 핸들러
.userInfoEndpoint()
.userService(oAuth2UserCustomService);
http.logout()
.logoutSuccessUrl("/login");
// /api로 시작하는 url인 경우 401 상태 코드를 반환하도록 예외 처리
http.exceptionHandling()
.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/api/**"));
return http.build();
}
@Bean
public OAuth2SuccessHandler oAuth2SuccessHandler() {
return new OAuth2SuccessHandler(tokenProvider,
refreshTokenRepository,
oAuth2AuthorizationRequestBasedOnCookieRepository(),
userService
);
}
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter() {
return new TokenAuthenticationFilter(tokenProvider);
}
@Bean
public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository() {
return new OAuth2AuthorizationRequestBasedOnCookieRepository();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
권한 인증 흐름에서 클라이언트의 요청을 유지하는 데 사용하는 AuthorizationRequestRepository
클래스를 구현해 쿠키를 사용해 OAuth의 정보를 가져오고 저장하는 로직을 작성해 보자.
package me.ansoohyeon.springbootdeveloper.config.oauth;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import me.ansoohyeon.springbootdeveloper.util.CookieUtil;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.web.util.WebUtils;
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);
}
}
기존 작성해 놓았던 BCryptPasswordEncoder
를 삭제하고, BCryptPasswordEncoder를 생성자를 사용해 직접 생성해서 패스워드를 암호화할 수 있도록 코드를 수정한다.
package me.ansoohyeon.springbootdeveloper.service;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.domain.User;
import me.ansoohyeon.springbootdeveloper.dto.AddUserRequest;
import me.ansoohyeon.springbootdeveloper.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@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"));
}
}
package me.ansoohyeon.springbootdeveloper.config.oauth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.config.jwt.TokenProvider;
import me.ansoohyeon.springbootdeveloper.domain.RefreshToken;
import me.ansoohyeon.springbootdeveloper.domain.User;
import me.ansoohyeon.springbootdeveloper.repository.RefreshTokenRepository;
import me.ansoohyeon.springbootdeveloper.service.UserService;
import me.ansoohyeon.springbootdeveloper.util.CookieUtil;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.time.Duration;
@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();
}
}
이제 글에 글쓴이를 추가하는 작업을 진행해 보자! Article
에 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에 작성자를 추가로 저장하기 위해 아래와 같이 코드를 추가해 준다.
package me.ansoohyeon.springbootdeveloper.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import me.ansoohyeon.springbootdeveloper.domain.Article;
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class AddArticleRequest {
private String title;
private String content;
public Article toEntity(String author) { // 생성자를 사용해 객체 생성
return Article.builder()
.title(title)
.content(content)
.author(author)
.build();
}
}
이 부분도 동일하게 진행된다.
(생략)
public Article save(AddArticleRequest request, String username){
return blogRepository.save(request.toEntity(userName));
}
현재 인증 정보를 가져오는 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);
}
글 상세 페이지에서도 글쓴이의 정보가 보여야 하므로 이 부분도 author
필드를 추가해 준다.
(생략)
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, updated_at) VALUES ('제목1', '내용1', 'user1', NOW(), NOW())
INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목2', '내용2', 'user2', NOW(), NOW())
INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목3', '내용3', 'user3', NOW(), NOW())
뷰에서 글쓴이의 정보를 알 수 있게 뷰를 수정한다.
(생략)
<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>
(생략)
컨트롤러의 뷰를 oauthLogin
으로 수정해 준다.
(생략)
@Controller
public class UserViewController {
@GetMapping("/login")
public String login(){
return "oauthLogin";
}
(생략)
구글 소셜 로그인 png 파일을 다운로드 해 준다. 그리고 이 이미지를 활용해 로그인 화면에 OAuth 연결 버튼을 생성해 보자.
<!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="/img/google.png">
</a>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
이제 자바 스크립트 파일을 생성해 주자. 이 코드는 파라미터로 받은 토큰이 있다면 토큰을 로컬 스토리지에 저장한다.
const token = searchParam('token')
if (token) {
localStorage.setItem("access_token", token)
}
function searchParam(key) {
return new URLSearchParams(location.search).get(key);
}
(생략)
<script src="/js/token.js"></script>
// 삭제 기능
const deleteButton = document.getElementById('delete-btn');
if (deleteButton != null) {
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 != null) {
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 != null) {
// 등록 버튼을 클릭하면 /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를 요청한다.
글을 수정하거나 삭제할 때 요청 헤더에 토큰을 전달하므로 사용자 자신이 작성한 글인지 검증할 수 있다. 따라서 본인 글이 아닌데 수정, 삭제를 시도하는 경우에 예외를 발생시키도록 코드를 수정한다.
(생략)
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");
}
}
}
이제 서버에서 실행해 보자!
구글 로그인이 구현되었다.. 😭
그런데 테스트를 실행해 보니 BlogApiControllerTest
가 실패했다! 그 부분을 고쳐 줄 것이다....
package me.ansoohyeon.springbootdeveloper.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import me.ansoohyeon.springbootdeveloper.domain.Article;
import me.ansoohyeon.springbootdeveloper.domain.User;
import me.ansoohyeon.springbootdeveloper.dto.AddArticleRequest;
import me.ansoohyeon.springbootdeveloper.dto.UpdateArticleRequest;
import me.ansoohyeon.springbootdeveloper.repository.BlogRepository;
import me.ansoohyeon.springbootdeveloper.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import java.security.Principal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@SpringBootTest
@AutoConfigureMockMvc
class BlogApiControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
private WebApplicationContext context;
@Autowired
BlogRepository blogRepository;
@Autowired
UserRepository userRepository;
User user;
@BeforeEach
public void mockMvcSetUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.build();
blogRepository.deleteAll();
}
@BeforeEach
void setSecurityContext() {
userRepository.deleteAll();
user = userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
SecurityContext context = SecurityContextHolder.getContext();
context.setAuthentication(new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities()));
}
@DisplayName("addArticle: 아티클 추가에 성공한다.")
@Test
public void addArticle() throws Exception {
// given
final String url = "/api/articles";
final String title = "title";
final String content = "content";
final AddArticleRequest userRequest = new AddArticleRequest(title, content);
final String requestBody = objectMapper.writeValueAsString(userRequest);
Principal principal = Mockito.mock(Principal.class);
Mockito.when(principal.getName()).thenReturn("username");
// when
ResultActions result = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.principal(principal)
.content(requestBody));
// then
result.andExpect(status().isCreated());
List<Article> articles = blogRepository.findAll();
assertThat(articles.size()).isEqualTo(1);
assertThat(articles.get(0).getTitle()).isEqualTo(title);
assertThat(articles.get(0).getContent()).isEqualTo(content);
}
@DisplayName("findAllArticles: 아티클 목록 조회에 성공한다.")
@Test
public void findAllArticles() throws Exception {
// given
final String url = "/api/articles";
Article savedArticle = createDefaultArticle();
// when
final ResultActions resultActions = mockMvc.perform(get(url)
.accept(MediaType.APPLICATION_JSON));
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].content").value(savedArticle.getContent()))
.andExpect(jsonPath("$[0].title").value(savedArticle.getTitle()));
}
@DisplayName("findArticle: 아티클 단건 조회에 성공한다.")
@Test
public void findArticle() throws Exception {
// given
final String url = "/api/articles/{id}";
Article savedArticle = createDefaultArticle();
// when
final ResultActions resultActions = mockMvc.perform(get(url, savedArticle.getId()));
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").value(savedArticle.getContent()))
.andExpect(jsonPath("$.title").value(savedArticle.getTitle()));
}
@DisplayName("deleteArticle: 아티클 삭제에 성공한다.")
@Test
public void deleteArticle() throws Exception {
// given
final String url = "/api/articles/{id}";
Article savedArticle = createDefaultArticle();
// when
mockMvc.perform(delete(url, savedArticle.getId()))
.andExpect(status().isOk());
// then
List<Article> articles = blogRepository.findAll();
assertThat(articles).isEmpty();
}
@DisplayName("updateArticle: 아티클 수정에 성공한다.")
@Test
public void updateArticle() throws Exception {
// given
final String url = "/api/articles/{id}";
Article savedArticle = createDefaultArticle();
final String newTitle = "new title";
final String newContent = "new content";
UpdateArticleRequest request = new UpdateArticleRequest(newTitle, newContent);
// when
ResultActions result = mockMvc.perform(put(url, savedArticle.getId())
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(request)));
// then
result.andExpect(status().isOk());
Article article = blogRepository.findById(savedArticle.getId()).get();
assertThat(article.getTitle()).isEqualTo(newTitle);
assertThat(article.getContent()).isEqualTo(newContent);
}
private Article createDefaultArticle() {
return blogRepository.save(Article.builder()
.title("title")
.author(user.getUsername())
.content("content")
.build());
}
}
이렇게 모든 테스트를 통과했다!
이렇게 OAuth2 + JWT + 스프링 시큐리티의 조합으로 인증 서비스를 만들어 보았다. 👍