본 프로젝트는 “스프링부트 3 백엔드 개발자 되기” 서적을 참고하여 진행하였음
스프링 시큐리티로 OAuth2를 구현하고 적용하기
•
스프링 시큐리티를 사용하여 OAuth2 구현 시작
•
가장 먼저 쿠키 관리 클래스를 구현하고, OAuth2에서 제공받은 인증 객체로 사용자 정보를 가져오는 역할을 하는 서비스를 구현하기
•
기존 ‘시큐리티 설정하기’ 에서 구현했던 WebSecurityConfig 클래스 대신 사용할 OAuth2 설정 파일을 구현하기
•
마지막에는 직접 테스트할 수 있도록 뷰를 구성
의존성 추가
•
build.gradle
dependencies {
... 생략 ...
// OAuth2를 사용하기 위한 스타터 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
Java
복사
쿠키 관리 클래스 구현
OAuth2 인증 플로우를 구현하며 쿠키를 사용할 일이 생기는데 그때마다 쿠키를 생성하고 삭제하는 로직을 추가하면 불편하므로 유틸리티를 사용할 쿠키 관리 클래스를 미리 구현한다.
package com.example.msblog.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())
)
);
}
}
Java
복사
•
addCookie
◦
요청값(이름, 값, 만료 기간)을 바탕으로 HTTP 응답에 쿠키를 추가한다.
•
deleteCookie
◦
쿠키 이름을 입력받아 쿠키를 삭제한다. 실제로 삭제하는 방법은 없으므로 파라미터로 넘어온 키의 쿠키를 빈 값으로 바꾸고 만료 시간을 0으로 설정해 쿠키가 재생성 되자마자 만료 처리한다.
•
serialize
◦
객체를 직렬화해 쿠키의 값으로 들어갈 값으로 변환한다.
•
deserialize
◦
쿠키를 역직렬화해 객체로 변환한다.
OAuth2 서비스 구현
•
사용자 정보를 조회하여 users 테이블에 사용자 정보가 있다면 리소스 서버에서 제공해주는 이름을 업데이트하고 없다면 users 테이블에 새 사용자를 생성해 데이터베이스에 저장하는 서비스를 구현한다.
•
package com.example.msblog.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 {
... 생략 ...
// 사용자 이름
@Column(name = "nickname", unique = true)
private String nickname;
@Builder
public User(String email, String password, String auth, String nickname) {
this.email = email;
this.password = password;
this.nickname = nickname; // OAuth2 사용 위한 닉네임 추가
}
// 사용자 이름 변경
public User update(String nickname) {
this.nickname = nickname;
return this;
}
}
Java
복사
•
config 패키지에 oauth 패키지를 만들고 OAuth2UserCustomService.java 파일을 생성한 다음 리소스 서버에서 보내주는 사용자 정보를 불러오는 메서드인 loadUser()를 통해 사용자를 조회하고, users 테이블에 사용자 정보가 있다면 이름을 업데이트하고 없다면 saveOrUpdate() 메서드를 실행하여 users 테이블에 회원 데이터를 추가한다.
package com.example.msblog.config.oauth2;
import com.example.msblog.domain.User;
import com.example.msblog.repository.UserRepository;
import lombok.RequiredArgsConstructor;
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);
}
}
Java
복사
부모 클래스인 DefaultOAuth2UserService에서 제공하는 OAuth 서비스에서 제공하는 정보를 기반으로 유저 객체를 만들어주는 loadUser() 메서드를 사용하여 사용자 객체를 불러온다. 사용자 객체는 식별자, 이름, 이메일, 프로필 사진 링크 등의 정보를 담고 있다. saveOrUpdate() 메서드는 사용자가 user테이블에 있으면 업데이트하고 없으면 사용자를 새로 생성해서 데이터베이스에 저장한다.
OAuth2 설정 파일 작성하기
OAuth2와 JWT를 함께 사용하려면 기존 스프링 시큐리티를 구현하며 작성한 설정이 아닌 다른 설정을 사용해야 하므로 OAuth2, JWT에 알맞게 파일 수정이 필요한다.
•
기존 폼 로그인 방식을 사용하기 위해 구성했던 설정 파일인 WebSecurityConfig.java 내용을 모두 주석 처리한 이후에 config 패키지에 WebOAuthSecurityConfig.java 파일을 생성한다.
package com.example.msblog.config;
import com.example.msblog.config.jwt.TokenProvider;
import com.example.msblog.config.oauth2.OAuth2UserCustomService;
import com.example.msblog.repository.RefreshTokenRepository;
import com.example.msblog.service.UserService;
import com.sun.net.httpserver.HttpsConfigurator;
import lombok.RequiredArgsConstructor;
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.oauth2.client.OAuth2AuthorizationSuccessHandler;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
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();
}
public OAuth2SuccessHandler oAuth2SuccessHandler() {
return new OAuth2SuccessHandler(tokenProvider,
refreshTokenRepository,
oAuth2AuthorizationRequestBasedOnCookieRepository(),
userService
);
}
@Bean
public ToeknAuthenticationFilter tokenAuthenticationFilter() {
return new TokenAuthenticationFilter(tokenProvider);
}
@Bean
public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository() {
return new OAuth2AuthorizationRequestBasedOnCookieRepository();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
Java
복사
•
filterchain() 메서드
◦
토큰 방식으로 인증을 하므로 기존 폼 로그인, 세션 기능을 비활성화한다.
•
addFilterBefore() 헤더값 확인용 커스텀 필터 추가
◦
헤더값을 확인할 커스텀 필터를 추가한다.
•
authorizeRequests() 메서드 URL 인증 설정
◦
토큰 재발급 URL은 인증 없이 접근하도록 설정하고 나머지 API들은 모두 인증을 해야 접근 가능하도록 설정한다.
•
oauth2Login() 메서드 이후 체인 메서드 수정
◦
OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소를 설정한다. 인증 성공 시 실행할 핸들러도 설정한다.
(해당 클래스는 아직 구현하지 않았으므로 에러 발생)
•
exceptionHandling() 메서드 예외 처리 설정
◦
/api로 시작하는 url인 경우 인증 실패 시 401 상태 코드 즉 Unauthorized를 반환한다.
OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소를 구현한다. config/oauth 패키지에 OAuth2AuthorizationRequestBasedOnCookieRepository.java 파일을 생성한다. 권한 인증 흐름에서 클라이언트의 요청을 유지하는 데 사용하는 AuthorizationRequestRepository 클래스를 구현하여 쿠키를 사용해 OAuth 정보를 가져오고 저장하는 로직을 구현한다.
package com.example.msblog.config.oauth2;
import com.example.msblog.util.CookieUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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);
}
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);
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request,
HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
}
Java
복사
그 다음에 인증 성공 시 실행할 핸들러를 구현한다. 해당 빈을 구현할 때 사용할 메서드를 만들기 위해 service 패키지의 UserService.java에서 다음과 같이 수정한다. BCryptPasswordEncoder 를 삭제하고 BCryptPasswordEncoder를 생성자를 사용해 직접 생성해서 패스워드를 암호화할 수 있게 코드를 수정한 다음 findByEmail() 메서드를 추가한다.
package com.example.msblog.service;
import com.example.msblog.domain.User;
import com.example.msblog.dto.AddUserRequest;
import com.example.msblog.repository.UserRepository;
import lombok.RequiredArgsConstructor;
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"));
}
// 메서드 추가 - 이메일을 입력받아 user테이블에서 유저를 찾고, 없으면 예외를 발생시킨다
public User findByEmail(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
}
Java
복사
•
package com.example.msblog.config.oauth2;
import com.example.msblog.config.jwt.TokenProvider;
import com.example.msblog.domain.RefreshToken;
import com.example.msblog.domain.User;
import com.example.msblog.repository.RefreshTokenRepository;
import com.example.msblog.service.UserService;
import com.example.msblog.util.CookieUtil;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
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, ServletException {
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();
}
}
Java
복사
스프링 시큐리티의 기본 로직에서는 별도의 authenticationSuccessHandler를 지정하지 않으면 로그인 성공 이후 SimpleUrlAuthenticationSuccessHandler를 사용한다. 일반적인 로직은 동일하게 사용하고, 토큰과 관련된 작업만 추가로 처리하기 위해 SimpleUrlAuthenticationSuccessHandler을 상속받은 뒤에 onAuthenticationSuccess() 메서드를 오버라이드한다.
•
리프레시 토큰 생성, 저장, 쿠키에 저장
◦
토큰 제공자를 사용해 리프레시 토큰을 만든 다음에, saveRefreshToken() 메서드를 호출해 해당 리프레시 토큰을 데이터베이스에 유저 아이디와 함께 저장한다. 그 이후에는 클라이언트에서 액세스 토큰이 만료되면 재발급 요청하도록 addRefreshTokenToCookie() 메서드를 호출해 쿠키에 리프레시 토큰을 저장한다.
•
액세스 토큰 생성, 패스에 액세스 토큰 추가
◦
토큰 제공자를 사용해 액세스 토큰을 만든 뒤에 쿠키에서 리다이렉트 경로가 담긴 값을 가져와 쿼리 파라미터에 액세스 토큰을 추가한다.
http://localhost:8080/articles?token=ej920129dkasdfjzZdsfajweriqldsafsdfjpqewr.....
Java
복사
•
인증 관련 설정값, 쿠키 제거
◦
인증 프로세스를 진행하면서 세션과 쿠키에 임시로 저장해둔 인증 관련 데이터를 제거한다. 기본적으로 제공하는 메서드인 clearAuthenticationAttributes()는 그대로 호출하고 removeAuthorizationRequestCookies()를 추가로 호출해 OAuth 인증을 위해 저장된 정보도 삭제한다.
•
리다이렉트
◦
‘액세스 토큰 생성, 패스에 액세스 토큰 추가’ 과정에서 만든 URL로 리다이렉트 한다.
글에 글쓴이 추가하기
•
domain 패키지의 Article.java에서 author 변수 추가 및 빌더 패턴에도 author 추가
package com.example.msblog.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import java.time.LocalDateTime;
@Entity // 엔티티로 지정
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Article {
... 생략 ...
// 글쓴이 추가하기
@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;
}
// 요청받은 내용으로 값을 수정
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
Java
복사
•
기존 글을 작성하는 API에서 작성자를 추가로 저장하기 위해 DTO 패키지의 AddArticleRequest.java 에서 toEntity() 메서드를 수정하여 author 값도 추가 저장할 수 있도록 한다.
package com.example.msblog.dto;
import com.example.msblog.domain.Article;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor // 기본 생성자 추가
@AllArgsConstructor // 모든 필드 값을 파라미터로 받는 생성자 추가
@Getter // Getter 안해주면 500 에러 발생하므로 주의!
public class AddArticleRequest {
private String title;
private String content;
public Article toEntity(String author) { // String author 추가
return Article.builder()
.title(title)
.content(content)
.author(author)
.build();
}
}
Java
복사
•
service 패키지의 BlogService.java의 save() 메서드에서 유저 이름을 추가로 입력받고 toEntity()의 인수로 전달받은 유저 이름을 반환하도록 코드를 수정한다.
package com.example.msblog.service;
import com.example.msblog.domain.Article;
import com.example.msblog.dto.AddArticleRequest;
import com.example.msblog.dto.UpdateArticleRequest;
import com.example.msblog.repository.BlogRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
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));
}
... 생략 ...
}
Java
복사
•
controller 패키지의 BlogApiController.java 에서 현재 인증 정보를 가져오는 principal 객체를 파라미터로 추가한다. 인증 객체에서 유저 이름을 가져온 뒤 save() 메서드로 넘겨준다.
package com.example.msblog.controller;
import com.example.msblog.domain.Article;
import com.example.msblog.dto.AddArticleRequest;
import com.example.msblog.dto.ArticleResponse;
import com.example.msblog.dto.UpdateArticleRequest;
import com.example.msblog.service.BlogService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RequiredArgsConstructor
@RestController // HTTP RESPONSE BODY에 객체 데이터를 JSON 형태로 반환
public class BlogApiController {
private final BlogService blogService;
// HTTP 메서드가 POST일 때 전달받은 URL과 동일하면 메서드로 매핑
@PostMapping("/api/articles")
// @RequestBody로 요청 본문 값 매핑
public ResponseEntity<Article> addArticle(
@RequestBody AddArticleRequest request, Principal principal) {
Article savedArticle = blogService.save(request, principal.getName());
// 요청한 자원이 성공적으로 생성되었으며 저장된 블로그 글 정보를 응답 객체에 담아 전송
return ResponseEntity.status(HttpStatus.CREATED)
.body(savedArticle);
}
... 생략 ...
}
Java
복사
•
package com.example.msblog.dto;
import com.example.msblog.domain.Article;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@NoArgsConstructor
@Getter
public class ArticleViewResponse {
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(); // 추가
}
}
Java
복사
•
스프링 부트 애플리케이션이 실행될 때마다 데이터를 추가하기 위해 data.sql 파일에도 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())
SQL
복사
•
뷰에서 작성자의 정보를 알 수 있도록 뷰를 수정한다. article.html 파일을 연 다음 작성자의 정보를 가져올 수 있게 코드를 수정한다.
<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>
HTML
복사
OAuth 뷰 구성하기
•
controller 패키지에서 UserViewController 파일 연 다음 login() 메서드의 뷰를 oauthLogin으로 변경
package com.example.msblog.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class UserViewController {
@GetMapping("/login")
public String login() {
return "oauthLogin";
}
@GetMapping("/signup")
public String signup() {
return "signup";
}
}
Java
복사
•
로그인 화면에서 사용할 이미지를 구글 로그인 브랜드 페이지에서 다운로드한다.
•
압축 파일 해제하고 png 파일을 복사한 다음 /resource/static/img 디렉터리를 만들고 복사한 파일을 붙여넣는다. 그런 다음에 파일명을 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 align-content-center">
<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>
</section>
</body>
</html>
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);
}
JavaScript
복사
•
articleList.html에서 token.js를 가져올 수 있도록 파일을 수정한다.
<body>
... 생략 ...
<script src="/js/token.js"></script> 추가
<script src="/js/article.js"></script>
HTML
복사
•
resource/js 패키지에 있는 article.js에 기존 createButton 관련 코드를 수정한다. 이 수정이 끝나면 토큰 기반 요청을 사용한다.
// 생성 기능
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) {
let result = null;
let cookie = document.cookie.split(":");
cookie.some(function (item) {
item = item.replace(" ", "");
let 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();
}
});
}
JavaScript
복사
이 코드는 POST 요청을 보낼 때 액세스 토큰도 함께 보낸다. 만약 응답에 권한이 없다는 에러 코드가 발생하면 리프레시 토큰과 함께 새로운 액세스 토큰을 요청하고, 전달받은 액세스 토큰으로 다시 API를 요청한다.
•
삭제, 수정 기능도 만들어 둔 httpRequest() 함수를 사용하도록 코드를 추가한다.
// 삭제 기능
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);
});
}
// 수정 기능
// id가 modify-btn인 엘리먼트 조회
const modifyButton = document.getElementById('modify-btn');
if (modifyButton) {
// 클릭 이벤트가 감지되면 수정 API 요청
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);
});
}
JavaScript
복사
글 수정, 삭제, 글쓴이 확인 로직 추가하기
글을 수정하거나 삭제할 때 요청 헤더에 토큰을 전달하므로 사용자 자신이 작성한 글인지 검증할 수 있다. 본인 글이 아닌데 수정, 삭제를 시도하는 경우에 예외를 발생시키도록 코드를 수정한다.
package com.example.msblog.service;
import com.example.msblog.domain.Article;
import com.example.msblog.dto.AddArticleRequest;
import com.example.msblog.dto.UpdateArticleRequest;
import com.example.msblog.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 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");
}
}
}
Java
복사
⇒ 수정, 삭제 메서드는 작업을 수행하기전 authorizeArticleAuthor() 메서드를 실행해 현재 인증 객체에 담겨 있는 사용자의 정보와 글을 작성한 사용자의 정보를 비교한다. 만약 서로 다르면 예외를 발생시켜 작업을 수행하지 않는다.
OAuth2 실행 테스트
•
구글 로그인 페이지 확인
•
로그인이 완료되면 /articles로 리다이렉트되며, 쿼리 파라미터에 token, 액세스 토큰을 요청 헤더로 전달한다. 그 뒤에 브라우저 메뉴에서 [도구 더보기] → [개발자 도구] 를 클릭해 개발자 도구를 열어 [Application]의 [Local Storage]를 클릭하면 스프링 부트 애플리케이션으로 부터 전달 받은 액세스 토큰을 저장한다는 것을 확인할 수 있다. 리프레시 토큰도 확인할 수 있다.
•
글 등록 테스트 - 글도 정상적으로 잘 등록됨
•
액세스 토큰이 만료되거나 삭제되면 어떤 API 요청 흐름이 발생하는지 알고자 [개발자 도구]의 [Application → Local Storage]에 들어간 후 access_token을 우클릭하고 [Delete] 를 눌러 값을 삭제해본다.
•
다시 /new-article로 이동한 뒤 글 등록을 시도해본다.
◦
정상적으로 잘 등록된다.
◦
액세스 토큰은 유효하지 않지만 리프레시 토큰이 있으므로 /token API를 호출해 새 액세스 토큰을 발급받아 인증을 다시 요청해 인증 필터를 통과하여 글이 잘 등록된다.
•
내가 작성하지 않은 글을 수정하거나 삭제하면 어떻게 되는지 확인