1. OAuth 개념 및 구글 토큰 발급받기
발급받은 '클라이언트 ID'와 '클라이언트 보안 비밀번호'는 application-secret.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=email,profile
2. 의존성 추가 및 쿠키 관리 클래스 구현
- 쿠키 관리 클래스를 구현
- OAuth2에서 제공받은 인증 객체로 사용자의 정보를 가져오는 역할을 하는 서비스를 구현
- WebSecurityConfig 클래스 대신, 사용할 OAuth2 설정 파일을 구현
의존성 추가하기
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // OAuth2를 사용하기 위한 스타터 추가
쿠키 관리 클래스 구현하기
- OAuth2 인증 플로우를 구현하며 쿠키를 사용할 일이 생기는데, 쿠키를 생성하고 삭제하는 로직을 담은 유틸리티 클래스를 구현해 둔다.
package me.progfrog.flog.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 {
/**
* 이름, 값, 만료 기간을 바탕으로 쿠키 추가
*/
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())));
}
}
- 참고) 쿠키를 삭제하는 방법은 없으므로, 파라미터로 넘어온 키의 쿠키를 빈 값으로 바꾸고, 만료 시간을 0으로 설정해 쿠키가 재생성되자마자 만료 처리를 한다.
3. OAuth2 서비스 구현하기
- 사용자 정보를 조회해 users 테이블에 사용자가 있다면 리소스 서버에서 제공해 주는 이름을 업데이트하고, 없다면 users 테이블에 새 사용자를 생성해 데이터베이스에 저장하는 서비스를 구현
User
- 이메일을 받아 User 엔티티를 생성할 수 있는 생성자 추가
package me.progfrog.flog.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
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;
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User extends TimeBaseEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(updatable = false)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column
private String password;
public User(String email) {
this.email = email;
}
public User(String email, String password) {
this.email = email;
this.password = password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("user"));
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
OAuth2UserCustomService
- 부모 클래스인 DefaultOAuth2UserService의 OAuth 서비스에서 제공하는 정보를 기반으로 사용자 객체를 만들어 주는 loadUser() 메서드를 사용해 사용자 객체를 불러온다.
- 사용자 객체는 식별자, 이름, 이메일, 프로필 사진 링크 등의 정보를 담고 있다.
- saveOrUpate() 메서드는 사용자가 user 테이블에 있으면 업데이트하고, 없으면 사용자를 새로 생성해서 데이터베이스에 저장한다.
package me.progfrog.flog.config.oauth;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.domain.User;
import me.progfrog.flog.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;
@Service
@RequiredArgsConstructor
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 void saveOrUpdate(OAuth2User oAuth2User) {
Map<String, Object> attributes = oAuth2User.getAttributes();
String email = (String) attributes.get("email");
User user = userRepository.findByEmail(email)
.orElse(new User(email));
userRepository.save(user);
}
}
OAuth2AuthorizationRequestBasedOnCookieRepository
- OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 사용할 수 있도록 인증 요청과 관련된 상태를 저장할 저장소를 구현
- 권한 인증 흐름에서 클라이언트의 요청을 유지하는 데 사용하는 AuthorizationRequestRepository 클래스를 구현해, 쿠키를 사용해 OAuth의 정보를 가져오고 저장하는 로직을 작성
package me.progfrog.flog.config.oauth;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import me.progfrog.flog.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 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);
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
}
}
OAuth2SuccessHandler
- 인증 성공 시 실행할 핸들러를 구현
- 해당 빈을 구현할 때 사용할 메서드를 만들기 위해 UserService 코드 일부 수정
- BCryptPasswordEncoder를 생성자를 사용해 직접 생성해서 패스워드를 암호화할 수 있도록 변경
- 이메일을 입력받아 users 테이블에서 유저를 찾고, 없으면 예외를 발생시키는 findByEmail() 메서드 추가
- OAuth2에서 제공하는 이메일은 유일 값이므로, 해당 메서드를 사용해 유저를 찾을 수 있다!
package me.progfrog.flog.service;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.domain.User;
import me.progfrog.flog.dto.user.UserReqAddDto;
import me.progfrog.flog.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public void save(UserReqAddDto reqAddDto) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
userRepository.save(new User(
reqAddDto.email(),
encoder.encode(reqAddDto.password()))
);
}
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.progfrog.flog.config.oauth;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.config.jwt.TokenProvider;
import me.progfrog.flog.domain.RefreshToken;
import me.progfrog.flog.domain.User;
import me.progfrog.flog.repository.RefreshTokenRepository;
import me.progfrog.flog.service.UserService;
import me.progfrog.flog.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;
@Component
@RequiredArgsConstructor
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, ServletException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email"));
// 1. 리프레시 토큰 생성 -> 저장 -> 쿠키에 저장
String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
saveRefreshToken(user.getId(), refreshToken);
addRefreshTokenToCookie(request, response, refreshToken);
// 2. 액세스 토큰 생성 -> 패스에 액세스 토큰 추가
String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
String targetUrl = getTargetUrl(accessToken);
// 3. 인증 관련 설정값, 쿠키 제거
clearAuthenticationAttributes(request, response);
// 4. 리다이렉트
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();
}
}
1. 리프레시 토큰 생성, 저장, 쿠키에 저장
- 토큰 제공자를 사용해 리프레시 토큰을 만든 뒤에, saveRefreshToken() 메서드를 호출해 해당 리프레시 토큰을 데이터베이스에 유저 아이디와 함께 저장
- 그 이후에는 클라이언트에서 액세스 토큰이 만료되면 재발급을 요청하도록 addRefreshTokenToCookie() 메서드를 호출해 쿠키에 리프레시 토큰을 저장
2. 액세스 토큰 생성, 패스에 액세스 토큰 추가
- 토큰 제공자를 사용해 액세스 토큰을 만든 뒤에 쿠키에서 리다이렉트 경로가 담긴 값을 가져와 쿼리 파라미터에 액세스 토큰을 추가
- 액세스 토큰을 클라이언트에 전달하는 예제는 아래와 같다.
http://localhost:8080/articles?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJoYXBweXByb...
3. 인증 관련 설정값, 쿠키 제거
- 인증 프로세스를 진행하면서 세션과 쿠키에 임시로 저장해 둔 인증 관련 데이터를 제거
- 기본적으로 제공하는 메서드인 clearAuthenticationAttributes()는 그대로 호출하고, removeAuthorizationRequestCookies()를 추가로 호출해 OAuth 인증을 위해 저장된 정보도 삭제
4. 리다이렉트
- 2에서 만든 URL로 리다이렉트
OAuth2 설정 파일 작성하기
- OAuth2와 JWT를 함께 사용하려면, 기존 스프링 시큐리티를 구현하며 작성한 설정이 아니라 다른 설정을 사용해야 한다.
- OAuth2, JWT에 알맞게 설정 파일을 수정하자!
- 기존에 구성했던 설정 파일인 WebSecurityConfig는 모두 주석 처리해 두자!
package me.progfrog.flog.config;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.config.jwt.TokenProvider;
import me.progfrog.flog.config.oauth.OAuth2AuthorizationRequestBasedOnCookieRepository;
import me.progfrog.flog.config.oauth.OAuth2SuccessHandler;
import me.progfrog.flog.config.oauth.OAuth2UserCustomService;
import me.progfrog.flog.repository.RefreshTokenRepository;
import me.progfrog.flog.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.annotation.web.configurers.AbstractHttpConfigurer;
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;
@Configuration
@RequiredArgsConstructor
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 {
// 1. 토큰 방식으로 인증을 하므로, 기존에 사용하던 폼 로그인, 세션 비활성화
http.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable);
// 2. 헤더를 확인할 커스텀 필터 추가
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// 3. 토큰 재발급 URL 인증 없이 접근 가능하도록 설정
http.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry
.requestMatchers("/api/token").permitAll()
.requestMatchers("/login", "/signup", "/user").permitAll()
.anyRequest().authenticated());
http.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.authorizationEndpoint(authorization -> authorization
.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())) // 4. Authorization 요청과 관련된 상태 저장
.successHandler(oAuth2SuccessHandler()) // 5. 인증 성공 시 실행할 핸들러
.userInfoEndpoint(userInfo -> userInfo
.userService(oAuth2UserCustomService)));
http.logout(logout -> logout
.logoutSuccessUrl("/login"));
// 6. /api 로 시작하는 url 인 경우 401 상태 코드를 반환하도록 예외 처리
http.exceptionHandling(exceptionHandling -> 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();
}
}
4. 글에 글쓴이 추가하기
Article
- author 추가
package me.progfrog.flog.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "article")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Article extends TimeBaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(updatable = false)
private Long id;
@Column(nullable = false)
private String author;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
public Article(String author, String title, String content) {
this.author = author;
this.title = title;
this.content = content;
}
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
DTO 수정
package me.progfrog.flog.dto.article;
import me.progfrog.flog.domain.Article;
import java.time.LocalDateTime;
public record ArticleDto(
Long id,
String author,
String title,
String content,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public ArticleDto(Article article) {
this(
article.getId(),
article.getAuthor(),
article.getTitle(),
article.getContent(),
article.getCreatedAt(),
article.getUpdatedAt()
);
}
}
package me.progfrog.flog.dto.article;
import me.progfrog.flog.domain.Article;
public record ArticleReqAddDto(
String title,
String content
) {
public Article toEntity(String author) {
return new Article(author, title, content);
}
}
package me.progfrog.flog.dto.article;
import java.time.LocalDateTime;
public record ArticleViewDto(
Long id,
String author,
String title,
String content,
LocalDateTime createdAt
) {
public ArticleViewDto(ArticleDto dto) {
this(
dto.id(),
dto.author(),
dto.title(),
dto.content(),
dto.createdAt()
);
}
}
BlogService
- save() 메서드에서 유저 이름을 추가로 입력받고, toEntity()에 넘겨주도록 코드를 수정한다.
- 글을 수정하거나 삭제할 때, 요청 헤더에 토큰을 전달하도록 해서 사용자가 자신이 작성한 글인지 검증하도록 한다.
- 만약, 본인 글이 아닌데 수정 및 삭제를 시도하는 경우는 예외가 발생한다.
package me.progfrog.flog.service;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.domain.Article;
import me.progfrog.flog.dto.article.ArticleDto;
import me.progfrog.flog.dto.article.ArticleReqAddDto;
import me.progfrog.flog.dto.article.ArticleReqUpdateDto;
import me.progfrog.flog.repository.BlogRepository;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional
public class BlogService {
private final BlogRepository blogRepository;
@Transactional(readOnly = true)
public List<ArticleDto> findAll() {
return blogRepository.findAll()
.stream()
.map(ArticleDto::new)
.toList();
}
@Transactional(readOnly = true)
public ArticleDto findById(Long articleId) {
return new ArticleDto(findArticleById(articleId));
}
public ArticleDto save(ArticleReqAddDto reqAddDto, String username) {
Article article = blogRepository.save(reqAddDto.toEntity(username));
return new ArticleDto(article);
}
public ArticleDto update(Long articleId, ArticleReqUpdateDto reqUpdateDto) {
Article article = findArticleById(articleId);
authorizeArticleAuthor(article);
article.update(reqUpdateDto.title(), reqUpdateDto.content());
return new ArticleDto(article);
}
public void delete(Long articleId) {
Article article = findArticleById(articleId);
authorizeArticleAuthor(article);
blogRepository.delete(article);
}
private Article findArticleById(Long articleId) {
return blogRepository.findById(articleId)
.orElseThrow(() -> new IllegalArgumentException("article not found: " + articleId));
}
private static void authorizeArticleAuthor(Article article) {
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
if (!article.getAuthor().equals(userName)) {
throw new IllegalArgumentException("not authorized");
}
}
}
BlogApiController
package me.progfrog.flog.api;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.dto.article.ArticleReqAddDto;
import me.progfrog.flog.dto.article.ArticleReqUpdateDto;
import me.progfrog.flog.dto.article.ArticleResDto;
import me.progfrog.flog.service.BlogService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/articles")
public class BlogApiController {
private final BlogService blogService;
// ...생략...
@PostMapping
public ResponseEntity<ArticleResDto> addArticle(@RequestBody ArticleReqAddDto reqAddDto, Principal principal) {
ArticleResDto resDto = new ArticleResDto(blogService.save(reqAddDto, principal.getName()));
return ResponseEntity.status(HttpStatus.CREATED)
.body(resDto);
}
// ...생략...
}
5. OAuth 뷰 구성하기
- 파일 다운로드
- 마음에 드는 로그인 버튼 이미지를 선택해서 /resources/static/img 폴더를 만들어 복사해 붙여 넣고, google.png로 이름 변경
UserViewController
package me.progfrog.flog.web;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
@RequiredArgsConstructor
public class UserViewController {
@GetMapping("/login")
public String getLoginForm() {
// return "users/login";
return "users/oauthLogin";
}
@GetMapping("/signup")
public String getSignupForm() {
return "users/signup";
}
}
article.js
- 이 코드는 POST 요청을 보낼 때, 액세스 토큰도 함께 보낸다.
- 만약 응답에 권한이 없다는 에러 코드가 발생하면 리프레시 토큰과 함께 새로운 액세스 토큰을 요청하고, 전달받은 액세스 토큰으로 다시 API를 요청한다.
// 생성 기능
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)
});
}
// 수정 기능
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 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);
});
}
// 쿠키를 가져오는 함수
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();
}
});
}
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
<!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 th:replace="~{fragments/bodyHeader :: bodyHeader}"/>
<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="article : ${articles}">
<div class="card">
<div class="card-header" th:text="${article.id}">
</div>
<div class="card-body">
<h5 class="card-title" th:text="${article.title}"></h5>
<p class="card-text" th:text="${article.content}"></p>
<a th:href="@{/articles/{articleId}(articleId=${article.id})}" class="btn btn-primary">보러가기</a>
</div>
</div>
<br>
</div>
</div>
<script src="/js/token.js"></script>
<script src="/js/article.js"></script>
</body>
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 th:replace="~{fragments/bodyHeader :: bodyHeader}"/>
<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>
oauthLogin.html
- resources/templates/user 하위에 파일 생성
<!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>
<div class = "mb-2">
<a href="/oauth2/authorization/google">
<img src="/img/google.png">
</a>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
테스트 코드 수정
package me.progfrog.flog.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import me.progfrog.flog.domain.Article;
import me.progfrog.flog.domain.User;
import me.progfrog.flog.dto.article.ArticleReqAddDto;
import me.progfrog.flog.dto.article.ArticleReqUpdateDto;
import me.progfrog.flog.repository.BlogRepository;
import me.progfrog.flog.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.transaction.annotation.Transactional;
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.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class BlogApiControllerTest {
private final static String URL_PREFIX = "/api/articles";
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
private WebApplicationContext context;
@Autowired
BlogRepository blogRepository;
@Autowired
UserRepository userRepository;
User user;
@BeforeEach
public void setUp() {
user = userRepository.save(new User("user@email.com", "test!!"));
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities()));
this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@DisplayName("getAllArticles(): 블로그 글 목록 조회에 성공한다.")
@Test
void getAllArticles() throws Exception {
// given
Article savedArticle = createDefaultArticle();
// when
final ResultActions resultActions = mockMvc.perform(get(URL_PREFIX)
.accept(MediaType.APPLICATION_JSON));
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value(savedArticle.getTitle()))
.andExpect(jsonPath("$[0].content").value(savedArticle.getContent()));
}
@DisplayName("getArticle(): 블로그 글 조회에 성공한다,")
@Test
void getArticle() throws Exception {
// given
final String url = URL_PREFIX + "/{articleId}";
Article savedArticle = createDefaultArticle();
// when
final ResultActions resultActions = mockMvc.perform(get(url, savedArticle.getId()));
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value(savedArticle.getTitle()))
.andExpect(jsonPath("$.content").value(savedArticle.getContent()));
}
@DisplayName("addArticle(): 블로그 글 추가에 성공한다.")
@Test
void addArticle() throws Exception {
// given
final String title = "title~~~";
final String content = "content!!";
final ArticleReqAddDto reqAddDto = new ArticleReqAddDto(title, content);
// 객체 -> JSON 직렬화
final String requestBody = objectMapper.writeValueAsString(reqAddDto);
Principal principal = Mockito.mock(Principal.class);
Mockito.when(principal.getName()).thenReturn("username");
// when
// 설정한 내용을 바탕으로 요청 전송
ResultActions result = mockMvc.perform(post(URL_PREFIX)
.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("updateArticle(): 블로그 글 수정에 성공한다.")
@Test
void updateArticle() throws Exception {
// given
final String url = URL_PREFIX + "/{articleId}";
Article savedArticle = createDefaultArticle();
final String newTitle = "new title!!!";
final String newContent = "new content~~";
ArticleReqUpdateDto reqUpdateDto = new ArticleReqUpdateDto(newTitle, newContent);
// when
ResultActions result = mockMvc.perform(put(url, savedArticle.getId())
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(reqUpdateDto)));
// then
result.andExpect(status().isOk());
Article article = blogRepository.findById(savedArticle.getId()).get();
assertThat(article.getTitle()).isEqualTo(newTitle);
assertThat(article.getContent()).isEqualTo(newContent);
}
@DisplayName("deleteArticle(): 블로그 글 삭제에 성공한다.")
@Test
void deleteArticle() throws Exception {
// given
final String url = URL_PREFIX + "/{articleId}";
Article savedArticle = createDefaultArticle();
// when
mockMvc.perform(delete(url, savedArticle.getId()))
.andExpect(status().isOk());
// then
List<Article> articles = blogRepository.findAll();
assertThat(articles).isEmpty();
}
private Article createDefaultArticle() {
String title = "title~~~";
String content = "content!!!";
return blogRepository.save(new Article(user.getUsername(), title, content));
}
}
6. OAuth2 실행 테스트하기
액세스 토큰과 리프레시 토큰을 확인할 수 있었다! 다음 시간에는 자잘한 버그들을 확인해 보고, 수정해 보도록 하겠다 :)
반응형