1. OAuth
- 제 3의 서비스에 계정 관리를 맡기는 방식
- 흔히 볼 수 있는 네이버로 로그인하기, 구글로 로그인하기 같은 방법
- OAuth를 사용하면 인증 서버에서 발급받은 토큰을 사용해서 리소스 서버에 리소스 오너의 정보를 요청하고 응답받아 사용할 수 있다.
리소스 오너(resource owner)
- 인증 서버에 자신의 정보를 사용하도록 허가하는 주체
- 서비스를 이용하는 사용자
리소스 서버(resource server)
- 리소스 오너의 정보를 가지며, 리소스 오너의 정보를 보호하는 주체
- 네이버, 구글, 페이스북이 리소스 서버에 해당
인증 서버(authorization server)
- 클라이언트에게 리소스 오너의 정보에 접근할 수 있는 토큰을 발급하는 역할을 하는 애플리케이션
클라이언트 애플리케이션(client application)
- 인증 서버에게 인증을 받고 리소스 오너의 리소스를 사용하는 주체
리소스 오너 정보를 취득하는 4가지 방법
권한 부여 코드 승인 타입 (authorization code grant type) |
클라이언트가 리소스에 접근하는 데 사용하며, 권한에 접근할 수 있는 코드와 리소스 오너에 대한 액세스 토큰을 발급받는 방식 |
암시적 승인 타입 (implicit grant type) |
서버가 없는 자바스크립트 웹 애플리케이션 클라이언트에서 주로 사용하는 방법. 클라이언트가 요청을 보내면 리소스 오너의 인증 과정 이외에는 권한 코드 교환 등의 별다른 인증 과정을 거치지 않고 액세스 토큰을 제공받는 방식 |
리소스 소유자 암호 자격증명 승인 타입 (resource owner password credentials) |
클라이언트의 패스워드를 이용해서 액세스 토큰에 사용자의 자격 증명을 교환하는 방식 |
클라이언트 자격증명 승인 타입 (client credentials grant) |
클라이언트가 컨텍스트 외부에서 액세스 토큰을 얻어 특정 리소스에 접근을 요청할 때 사용하는 방식 |
1) 권한 부여 코드 승인 타입
권한 요청
- 클라이언트, 즉, 스프링 부트 서버가 특정 사용자 데이터에 접근하기 위해 권한 서버, 즉 , 카카오나 구글 권한 서버에 요청을 보내는 것
- 요청 URI는 권한 서버마다 다르지만 보통은 클라이언트 ID, 리다이렉트 URI, 응답 타입 등을 파라미터로 보낸다.
ex) 권한 요청을 위한 파라미터
GET spring-authorization-server.example/authorize?
client_id=66a36b4c2d& //인증 서버가 클라이언트에 할당한 고유 식별자
redirect_uri=http://localhost:8080/myapp& //로그인 시 이동해야하는 URI
response_type=code& //클라이언트가 제공받길 원하는 응답 타입
scope=profile //제공받고자 하는 리소스 오너의 정보 목록
데이터 접근용 권한 부여
- 인증 서버에 요청을 처음 보내는 경우 사용자에게 보이는 페이지를 로그인 페이지로 변경하고 사용자의 데이터에 접근 동의를 얻는다. 이 과정을 최초 1회만 진행된다. 이후에는 인증 서버에서 동의 내용을 저장하고 있기 때문에 로그인만 진행된다. 로그인이 성공되면 권한 부여 서버는 데이터에 접근할 수 있게 인증 및 권한 부여를 수신한다.
인증 코드 제공
- 사용자가 로그인에 성공하면 권한 요청 시에 파라미터로 보낸 redirect_uri로 리다이렉션 된다. 이때 파라미터에 인증코드를 함께 제공한다.
GET http://localhost:8080/myapp?code=a1s2f3mcj2
액세스 토큰 응답
- 인증 코드를 받으면 액세스 토큰으로 교환해야 한다. 액세스 토큰은 로그인 세션에 대한 보안 자격을 증명하는 식별 코드를 의미한다.
ex) /token POST 요청
POST spring-authorization-server.example.com/token
{
"client_id":"66a36b4c2",
"client_secret":"aabb11dd44", //OAuth 서비스에 등록할 때 제공받는 비밀키
"redirect_uri":"http://localhost:8080/myapp",
//권한 유형 확인
//권한 서버는 요청 값을 기반으로 유효한 정보인지 확인하고, 유효한 정보라면 액세스 토큰으로 응답
"grant_type":"authorization_code",
"code":"a1b2c3d4e5f6g7h8"
}
액세스 토큰으로 API 응답 & 반환
- 제공받은 액세스 토큰으로 리소스 오너의 정보를 가져올 수 있다.
- 정보가 필요할 때마다 API 호출을 통해 정보를 가져오고 리소스 서버는 토큰이 유효한지 검사한 뒤에 응답한다.
2) 쿠키
- 사용자가 어떠한 웹사이트를 방문했을 때 해당 웹사이트의 서버에서 로컬 환경에 저장하는 작은 데이터
- 이 값이 있기 때문에 이전에 방문한 적이 있는지 알 수 있고, 이전에 로그인을 했다면 로그인 정보도 유지할 수 있다.
- 쿠키는 키와 값으로 이루어져 있으며 만료 기간, 도메인 등의 정보를 가지고 있다.
- HTTP 요청을 통해 쿠키의 특정 키에 값을 추가할 수 있다.
- 클라이언트가 정보를 요청하면 서버에서 정보를 값으로 넣은 쿠키를 생성해서 요청한 정보, 즉 HTTP 헤더와 함께 돌려보낸다.
- 그러면 클라이언트는 로컬 즉, 브라우저에 쿠키를 저장한다.
- 이후 사이트를 재방문할 때는 사용자가 로컬 환경에 있는 쿠키와 함께 서버에 요청한다.
- 이렇게 하면 클라이언트에 값을 저장할 수 있기 때문에 현재 사용자 관련 정보를 보여줄 수 있다.
2. 토큰 발급받기
- 구글 로그인 기능을 추가하기 위해 인증 서버에게 토큰 제공받기
구글 클라우드 콘솔
https://cloud.google.com/cloud-console?hl=ko
- 사용자 인증 정보를 만들려면 OAuth 동의 화면을 먼저 구성해야 한다.
- User Type을 외부용으로 선택한 후 [만들기] 버튼을 클릭한다.
- OAuth 설정값을 스프링 부트 애플리케이션 설정 파일에서 사용하기 위해 applicaqtion.yml 파일을 열고 다음 내용을 추가
application.yml
security:
oauth2:
client:
registration:
google:
client-id:
client-secret:
scope:
-email
-profile
3. 스프링 시큐리티로 OAuth2를 구현하고 적용하기
- 쿠키 관리 클래스를 구현하고, OAuth2에서 제공받은 인증 객체로 사용자 정보를 가져오는 역할을 하는 서비스를 구현
- OAuth2 설정 파일 구현
- 직접 테스트하도록 뷰를 구성
1) 의존성 추가하기
build.gradle
...
//OAuth2를 사용하기 위한 스타터 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
2) 쿠키 관리 클래스 구현하기
- 유틸리티로 사용할 쿠키 관리 클래스를 구현
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);
}
//쿠키의 이름을 입력받아 쿠키 삭제
//파라미터로 넘어온 키의 쿠키를 빈 값으로 바꾸고 만료 시간을 0으로 설정해 쿠키가 재생성 되지마자 만료 처리
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())
)
);
}
}
3) OAuth2 서비스 구현하기
- 사용자 정보를 조회해 users 테이블에 사용자 정보가 있다면 리소스 서버에서 제공해주는 이름을 업데이트
- 사용자 정보가 없다면 users 테이블에 새 사용자를 생성해 데이터베이스에 저장하는 서비스 구현
domain/User.java
- 사용자 이름 추가
//사용자 이름
@Column(name="nickname", unique=true)
private String 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에서 제공하는 OAuth 서비스에서 제공하는 정보를 기반으로 유저 객체를 만들어주는 loadUser() 메서드를 사용해 사용자 객체를 불러온다. 사용자 객체는 식별자, 이름, 이메일, 프로필 사진 링크 등의 정보를 담고 있다.
- saveOrUpdate() 메서드는 사용자가 user 테이블에 있으면 업데이트하고 없으면 사용자를 새로 생성해서 데이터베이스에 저장한다.
4) OAuth2 설정 파일 작성하기
- OAuth2.JWT에 알맞게 설정 파일을 수정
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(toH2Console())
.requestMatchers(
new AntPathRequestMatcher("/img/**"),
new AntPathRequestMatcher("/css/**"),
new AntPathRequestMatcher("/js/**")
);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//토큰 방식으로 인증을 하기 때문에 기존에 사용하던 폼 로그인, 세션 비활성화
return http
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
//헤더를 확인할 커스텀 필터 추가
.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
//토큰 재발급 URL은 인증없이 접근 가능하도록 설정. 나머지 API URL은 인증 필요
.authorizeRequests(auth -> auth
.requestMatchers(new AntPathRequestMatcher("/api/token")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/**")).authenticated()
.anyRequest().permitAll())
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
//Authorization 요청과 관련된 상태 저장
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository()))
.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(oAuth2UserCustomService))
//인증 성공 시 실행할 핸들러
.successHandler(oAuth2SuccessHandler())
)
// /api로 시작하는 url인 경우 401 상태 코드를 반환하도록 예외 처리
.exceptionHandling(exceptionHandling -> exceptionHandling
.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/api/**")
))
.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();
}
}
OAuth2AuthorizationRequestBasedOnCookieRepository.java
- OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소를 구현
- 권한 인증 흐름에서 클라이언트의 요청을 유지하는 데 사용하는 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);
}
}
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"));
}
}
config/oauth/OAuth2SuccesssHandler.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"));
//리프레시 토큰 생성 -> 저장 -> 쿠키에 저장
//토큰 제공자를 사용해 리프레시 토큰을 만든 뒤에, saveRefreshToken() 메서드를 호출해
//리프레시 토큰을 데이터베이스에 유저 아이디와 함께 저장
//그 이후에는 클라이언트에서 액세스 토큰이 만료되면 재발급 요청하도록 addRefreshTokenToCookie() 메서드를 호출해 쿠키에 리프레시 토큰 저장
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();
}
}
5) 글에 글쓴이 추가하기
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;
}
dto/AddArticleRequest.java
- 기존 글을 작성하는 API에서 작성자를 추가로 저장하기 위해 toEntity() 메서드를 수정해 author 값도 추가 저장하도록 변경
public Article toEntity(String author) { //생성자를 사용해 객체 생성
return Article.builder()
.title(title)
.content(content)
.author(author)
.build();
}
BlogService.java
- save() 메서드에서 유저 이름을 추가로 입력받고 toEntity()의 인수를 전달받은 유저 이름을 반환하도록 코드를 수정
public Article save(AddArticleRequest request, String userName) {
return blogRepository.save(request.toEntity(userName));
}
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);
}
...
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();
}
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())
6) OAuth 뷰 구성하기
UserViewController.java
- login() 메서드의 뷰를 oauthLogin으로 변경
...
@GetMapping("/login")
public String login() {
return "oauthLogin";
}
...
oauthLogin.html
- 로그인 화면에 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>
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를 가져올 수 있도록 파일을 수정
...
<script src="/js/token.js"></script>
<script src="/js/article.js"></script>
article.js
- 기존 createButton 관련 코드를 수정
- POST 요청을 보낼 때 액세스 토큰도 함께 보낸다.
- 만약에 응답에 권한이 없다는 에러 코드가 발생하면 리프레시 토큰과 함께 새로운 액세스 토큰을 요청하고, 전달받은 액세스 토큰으로 다시 API를 요청
// 삭제 기능
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)
});
}
// 로그아웃 기능
const logoutButton = document.getElementById('logout-btn');
if (logoutButton) {
logoutButton.addEventListener('click', event => {
function success() {
// 로컬 스토리지에 저장된 액세스 토큰을 삭제
localStorage.removeItem('access_token');
// 쿠키에 저장된 리프레시 토큰을 삭제
deleteCookie('refresh_token');
location.replace('/login');
}
function fail() {
alert('로그아웃 실패했습니다.');
}
httpRequest('DELETE','/api/refresh-token', 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;
}
// 쿠키를 삭제하는 함수
function deleteCookie(name) {
document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT;';
}
// 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();
}
});
}
7) 글 수정, 삭제, 글쓴이 확인 로직 추가하기
BlogService.java
- 글을 수정하거나 삭제할 때 요청 헤더에 토큰을 전달하므로 사용자 자신이 작성한 글인지 검증할 수 있다.
- 따라서 본인 글이 아닌데 수정, 삭제를 시도하는 경우에 예외를 발생하도록 코드를 수정
@RequiredArgsConstructor
@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");
}
}
}
'SpringBoot' 카테고리의 다른 글
[스프링 부트 3 백엔드 개발자 되기] ch 12 CI/CD 도입하기 (0) | 2024.07.26 |
---|---|
[스프링 부트 3 백엔드 개발자 되기] ch 11. AWS에 프로젝트 배포하기 (0) | 2024.07.26 |
[스프링 부트 3 백엔드 개발자 되기] ch 9. JWT로 로그인/로그아웃 구현하기 (1) | 2024.07.24 |
[스프링 부트 3 백엔드 개발자 되기] ch 8. 스프링 시큐리티로 로그인/로그아웃, 회원가입 구현하기 (28) | 2024.07.19 |
[스프링 부트 3 백엔드 개발자 되기] ch 7. 블로그 화면 구성하기 (0) | 2024.07.17 |