이전 내용
[Java] Spring Boot: 스프링 시큐리티, OAuth2, JWT
이전 내용 [Java] Spring Boot: 네이버 로그인 구현하기이전 내용 [Java] Spring Boot: 카카오 로그인 기능 추가하기이전 내용 [Java] Spring Boot: 구글 로그인 기능 추가하기이전 내용 전반적인 순서1. Gradle 또
puppy-foot-it.tistory.com
블로그 앱 만들기
CRUD (생성, 조회, 업데이트, 삭제) 기능이 지원되는 블로그 Rest API를 만든다.
※ 활용 기술: 스프링 부트, 스프링 데이터 JPA, 롬복(Lombok), H2
[최종]
엔티티 구성하기
1. 엔티티 구성하기
[만들 엔티티와 매핑되는 테이블 구조]
컬럼명 | 자료형 | null 허용여부 | 키 | 설명 |
id | BIGINT | 불허 | Primary Key | 일련번호, 기본키 |
title | VARCHAR(255) | 불허 | 게시물의 제목 | |
content | VARCHAR(255) | 불허 | 내용 |
패키지에 domain 패키지 생성하고 Article.java 파일을 만들고, 롬복의 애너테이션을 사용하여 코드 작성.
또한, 엔티티에 생성 시간과 수정 시간을 추가해 글이 언제 생성되었는지 뷰에서 확인할 수 있도록 한다.
"author" 변수의 경우, OAuth2 서비스 위한 로직을 모두 추가한 후 글에 글쓴이를 추가하는 작업 구현 시 필요하므로, 미리 추가하고, 이후에 빌더 패턴에서도 author를 추가해 객체를 생성할 때 글쓴이(author)를 입력받을 수 있게 한다.
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 {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(updatable = false)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
@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;
}
@CreatedDate // 엔티티가 생성될 때 생성 시간 저장
@Column(name = "created_at")
private LocalDateTime createdAt;
@LastModifiedDate // 엔티티가 수정될 때 수정 시간 저장
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
2. 리포지토리 만들기
패키지에 repository 패키지를 만들고 해당 패키지 내에 BlogRepository.java 파일을 생성해 BlogRepository 인터페이스 생성.
JpaRepository 클래스를 상속받을 때 엔티티 Article과 엔티티의 PK 타입 Long을 인수로 넣는다.
이 리포지토리를 통해 JpaRepository에서 제공하는 여러 메소드를 사용할 수 있다.
import com.example.spro01.domain.Article;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BlogRepository extends JpaRepository<Article, Long> {
}
블로그 기능 API 구현하기
해당 내용 및 코드는 이전에 만들었던 방명록 Rest API를 참고하면 된다. 여기서는 대략적인 순서만 소개한다.
[Java] Spring Boot: 방명록 Rest API 구현(feat. MySQL 연동)
이전 내용 [Java] Spring Boot: 코드, 요청&응답 과정 이해하기이전 내용 [Java] Spring Boot: Maven Repository이전 내용 [Java] 스프링부트: Spring Initializr - Dependencies이전 내용 [Java] Spring Boot: 제어 역전, 의존성
puppy-foot-it.tistory.com
[대략적인 순서]
- 서비스 클래스에서 메소드 구현
- 컨트롤러에서 사용할 메소드 구현
- API 테스트
- 테스트 코드 작성
[글 추가 기능]
- 먼저 패키지 내에 dto라는 패키지를 만들고 그 안에 서비스 계층에서 요청을 받을 객체인 AddArticleRequest 객체를 생성한다.
- 그리고 패키지 내에 Service 라는 패키지를 만들고 그 안에 BlogService.java 파일 생성 후, BlogService 클래스 생성 후 블로그 글 추가 메소드인 save() 구현.
- URL에 매핑하기 위한 컨트롤러 메소드 추가를 위해 패키지에 controller 패키지를 생성한 뒤, BlogAPIController.java 파일을 생성해 컨트롤러 메소드 작성.
- 로컬 서버를 실행하고 H2 Console에 접속해서 실행 테스트
- 테스트 코드 작성
[전체 글 조회 기능]
- BlogService.java 파일에 데이터베이스에 저장되어 있는 글을 모두 가져오는 findAll() 메소드 추가
- dto 디렉토리(패키지)에 ArticleResponse.java 파일을 생성해 GET 요청이 오면 글 목록을 조회할 findAllArticles() 메소드 작성
- controller 패키지의 BlogApiController.java 파일 내에 전체 글을 조회한 뒤 반환하는 findAllArticles() 메소드 추가
- resource 디렉토리에 data.sql 파일을 생성하여 SQL 문 작성하고 포스트맨으로 테스트
- 실행 파일(application.java) 을 열어 엔티티의 creaated_at, updated_at 을 자동으로 업데이트하기 위한 @EnableJpaAuditing 애너테이션 추가
- 테스트 코드 작성
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())
[특정 글 조회 기능]
- BlogService.java 파일에 블로그 글 하나를 조회하는 findById() 메소드 추가
- BlogApiController.java 파일에 /api/articles/{id} GET 요청이 오면 블로그 글을 조회하기 위해 매핑할 findArticle() 메소드 작성
- 테스트 코드 작성
[블로그 글 삭제 기능]
- BlogService.java 파일에 ID에 해당하는 블로그 글을 삭제하는 API를 구현하기 위해 delete() 메소드 추가
- BlogApiController.java 파일에 /api/articles/{id} DELETE 요청이 오면 글을 삭제하기 위한 findArticle() 메소드 작성
- 포스트맨을 통해 실행 테스트
- 테스트 코드 작성
[블로그 글 수정 기능]
- Articles.java 파일에 엔티티에 요청받은 내용으로 값을 수정하는update() 메소드 작성
- 블로그 글 수정 요청을 받을 DTO 작성을 위해 dto 패키지에 UpdateArticleRequest.java 파일을 생성하여 코드 작성
- BlogService.java 파일을 열어 리포지토리를 사용해 글을 수정하는 update() 메소드 추가
- BlogApiController.java 파일에 /api/articles/{id} PUT 요청이 오면 글을 수정하기 위한 updateArticle() 메소드 작성
- 포스트맨을 통해 실행 테스트
- 테스트 코드 작성
블로그 화면 개발
템플릿 엔진인 타임리프를 사용하여 블로그 화면을 개발한다.
※ 템플릿 엔진: 스프링 서버에서 데이터를 받아 우리가 보는 웹 페이지(HTML 등)에 데이터를 넣어 보여주는 도구.
◆ 타임리프 사용을 위해 의존성을 추가한다. (build.gradle 파일의 dependencies 영역)
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
◆ 컨트롤러 메소드 작성
뷰에게 데이터를 전달하기 위한 객체를 생성하기 위해, dto 패키지에 ArticleListViewResponse.java 파일을 만들고 코드 작성
import com.example.spro01.domain.Article;
import lombok.Getter;
@Getter
public class ArticleListViewResponse {
private final Long id;
private final String title;
private final String content;
public ArticleListViewResponse(Article article) {
this.id = article.getId();
this.title = article.getTitle();
this.content = article.getContent();
}
}
블로그 글 전체 리스트를 담은 뷰를 반환하기 위해, controller 패키지에 BlogViewController.java 파일을 만들어 /articles GET 요청을 처리할 코드를 작성한다.
또한, 블로그 글을 반환할 컨트롤러 메소드 작성을 위해 getArticle() 메소드도 추가해 준다.
마찬가지로, 수정 화면을 보여주기 위한 컨트롤러 메소드인 newArticle() 메소드도 추가한다.
import com.example.spro01.domain.Article;
import com.example.spro01.dto.ArticleListViewResponse;
import com.example.spro01.dto.ArticleViewResponse;
import com.example.spro01.service.BlogService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@RequiredArgsConstructor
@Controller
public class BlogViewController {
private final BlogService blogService;
@GetMapping("/articles")
public String getArticles(Model model) {
List<ArticleListViewResponse> articles = blogService.findAll().stream()
.map(ArticleListViewResponse::new)
.toList();
model.addAttribute("articles", articles); // 블로그 글 리스트 저장
return "articleList"; // articleList.html 뷰 조회
}
@GetMapping("/articles/{id}")
public String getArticle(@PathVariable Long id, Model model) {
Article article = blogService.findById(id);
model.addAttribute("article", new ArticleViewResponse(article));
return "article";
}
@GetMapping("/new-article")
// id 키를 가진 쿼리 파라미터의 값을 id 변수에 매핑 (id는 없을 수도 있음)
public String newArticle(@RequestParam(required = false) Long id, Model model) {
if (id == null) { // id가 없으면 생성
model.addAttribute("article", new ArticleViewResponse());
} else { // id가 없으면 수정
Article article = blogService.findById(id);
model.addAttribute("article", new ArticleViewResponse(article));
}
return "newArticle";
}
}
HTML 뷰 만들고 테스트
resource/templates 디렉토리에 articleList.html 파일을 만들고 모델에 전달한 블로그 글 리스트 개수만큼 반복해 글 정보를 보여주도록 코드 작성하고, id가 create-btn인 [생성] 버튼도 추가해 준다.
또한, 로그아웃을 위한 뷰를 만들기 위해 [로그아웃] 버튼도 추가해 준다.
추후 OAuth2 로그인 구현 시에 필요한 자바스크립트 파일(token.js)도 가져올 수 있도록 코드를 추가해 준다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글 목록</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>
<div class="container">
<button type="button" id="create-btn"
th:onclick="|location.href='@{/new-article}'|"
class="btn btn-secondary btn-sm mb-3">글 등록</button>
<div class="row-6" th:each="item : ${articles}">
<div class="card">
<div class="card-header" th:text="${item.id}">
</div>
<div class="card-body">
<h5 class="card-title" th:text="${item.title}"></h5>
<p class="card-text" th:text="${item.content}"></p>
<a th:href="@{/articles/{id}(id=${item.id})}" class="btn btn-primary">보러가기</a>
</div>
</div>
<br>
</div>
<button type="button" class="btn btn-secondary" onclick="location.href='/logout'">로그아웃</button>
</div>
<script src="/js/token.js"></script>
<script src="/js/article.js"></script>
</body>
그리고 resource/templates 디렉토리에 article.html을 만들어 화면을 작성한다.
뷰에서 글쓴이의 정보를 알 수 있게 글쓴이의 정보를 가져올 수 있게 작성하는 작업도 같이 해준다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>
<div class="container mt-5">
<div class="row">
<div class="col-lg-8">
<article>
<input type="hidden" id="article-id" th:value="${article.id}">
<header class="mb-4">
<h1 class="fw-bolder mb-1" th:text="${article.title}"></h1>
<div class="text-muted fst-italic mb-2" th:text="|Posted on ${#temporals.format(article.createdAt, 'yyyy-MM-dd HH:mm')} By ${article.author}|"></div>
</header>
<section class="mb-5">
<p class="fs-5 mb-4" th:text="${article.content}"></p>
</section>
<button type="button" id="modify-btn"
th:onclick="|location.href='@{/new-article?id={articleId}(articleId=${article.id})}'|"
class="btn btn-primary btn-sm">수정</button>
<button type="button" id="delete-btn"
class="btn btn-secondary btn-sm">삭제</button>
</article>
</div>
</div>
</div>
<script src="/js/article.js"></script>
</body>
수정 및 생성 뷰를 만들기 위해 resource/templates 디렉토리에 newArticle.html 파일을 생성해 코드를 작성한다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>
<div class="container mt-5">
<div class="row">
<div class="col-lg-8">
<article>
<input type="hidden" id="article-id" th:value="${article.id}">
<header class="mb-4">
<input type="text" class="form-control" placeholder="제목" id="title" th:value="${article.title}">
</header>
<section class="mb-5">
<textarea class="form-control h-25" rows="10" placeholder="내용" id="content" th:text="${article.content}"></textarea>
</section>
<button th:if="${article.id} != null" type="button" id="modify-btn" class="btn btn-primary btn-sm">수정</button>
<button th:if="${article.id} == null" type="button" id="create-btn" class="btn btn-primary btn-sm">등록</button>
</article>
</div>
</div>
</div>
<script src="/js/article.js"></script>
</body>
등록, 삭제, 수정, 생성 기능 코드를 구현하기 위해
src/main/resource/static 디렉토리에 js 디렉토리를 만들고 atricle.js 파일을 만들어 코드를 작성한다.
// 삭제 기능
const deleteButton = document.getElementById('delete-btn');
if (deleteButton) {
deleteButton.addEventListener('click', event => {
let id = document.getElementById('article-id').value;
function success() {
alert('삭제가 완료되었습니다.');
location.replace('/articles');
}
function fail() {
alert('삭제 실패했습니다.');
location.replace('/articles');
}
httpRequest('DELETE',`/api/articles/${id}`, null, success, fail);
});
}
// 수정 기능
const modifyButton = document.getElementById('modify-btn');
if (modifyButton) {
modifyButton.addEventListener('click', event => {
let params = new URLSearchParams(location.search);
let id = params.get('id');
body = JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
})
function success() {
alert('수정 완료되었습니다.');
location.replace(`/articles/${id}`);
}
function fail() {
alert('수정 실패했습니다.');
location.replace(`/articles/${id}`);
}
httpRequest('PUT',`/api/articles/${id}`, body, success, fail);
});
}
// 생성 기능
const createButton = document.getElementById('create-btn');
if (createButton) {
// 등록 버튼을 클릭하면 /api/articles로 요청을 보낸다
createButton.addEventListener('click', event => {
body = JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
});
function success() {
alert('등록 완료되었습니다.');
location.replace('/articles');
};
function fail() {
alert('등록 실패했습니다.');
location.replace('/articles');
};
httpRequest('POST','/api/articles', body, success, fail)
});
}
// 쿠키를 가져오는 함수
function getCookie(key) {
var result = null;
var cookie = document.cookie.split(';');
cookie.some(function (item) {
item = item.replace(' ', '');
var dic = item.split('=');
if (key === dic[0]) {
result = dic[1];
return true;
}
});
return result;
}
// HTTP 요청을 보내는 함수
function httpRequest(method, url, body, success, fail) {
fetch(url, {
method: method,
headers: { // 로컬 스토리지에서 액세스 토큰 값을 가져와 헤더에 추가
Authorization: 'Bearer ' + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
},
body: body,
}).then(response => {
if (response.status === 200 || response.status === 201) {
return success();
}
const refresh_token = getCookie('refresh_token');
if (response.status === 401 && refresh_token) {
fetch('/api/token', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
},
body: JSON.stringify({
refreshToken: getCookie('refresh_token'),
}),
})
.then(res => {
if (res.ok) {
return res.json();
}
})
.then(result => { // 재발급이 성공하면 로컬 스토리지값을 새로운 액세스 토큰으로 교체
localStorage.setItem('access_token', result.accessToken);
httpRequest(method, url, body, success, fail);
})
.catch(error => fail());
} else {
return fail();
}
});
}
컨트롤러 메소드 작성하기
뷰에서 사용할 DTO를 만들기 위해 dto 디렉토리에 ArticleViewResponse.java 파일을 생성한 뒤 클래스 구현
추후 OAuth2 서비스 구현 시 작성자 (author) 정보도 필요하므로, 해당 내용도 추가한다.
import com.example.spro01.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();
}
}
스프링 시큐리티 설정하기
◆ 의존성 추가
스프링 시큐리티를 사용하기 위해 build.gradle 파일에 의존성을 추가하고 빌드를 새로 고침.
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
testImplementation 'org.springframework.security:spring-security-test'
◆ 엔티티 만들기
칼럼명 | 자료형 | null 허용 | 키 | 설명 |
id | BIGINT | N | PK | 일련번호, 기본키 |
VARCHAR(255) | N | 이메일 | ||
password | VARCHAR(255) | N | 패스워드 | |
created_at | DATETIME | N | 생성일자 | |
updated_at | DATETIME | N | 수정일자 |
◆ User 클래스 만들기
domian 패키지에 User.java 파일을 생성하고 UserDetails 클래스를 상속하는 User 클래스 생성
여기에는 추후에 OAuth2 (소셜 로그인) 서비스 구현 시 users 테이블에 사용자 정보를 업데이트 또는 추가해 주는 코드를 추가해 준다.
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 { // 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;
@Builder
public User(String email, String password, String nickname) {
this.email = email;
this.password = password;
this.nickname = nickname;
}
@Override // 권한 반환
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("user"));
}
// 사용자의 id 반환(고윳값)
@Override
public String getUsername() {
return email;
}
// 사용자의 패스워드 반환
@Override
public String getPassword() {
return password;
}
// 계정 만료 여부 반환
@Override
public boolean isAccountNonExpired() {
return true; // true -> 만료되지 않음
}
// 계정 잠금 여부 반환
@Override
public boolean isAccountNonLocked() {
return true; // true -> 잠금되지 않음
}
// 패스워드 만료 여부 반환
@Override
public boolean isCredentialsNonExpired() {
return true; // true -> 만료되지 않음
}
// 계정 사용 가능 여부 반환
@Override
public boolean isEnabled() {
return true; // true -> 사용 가능
}
// 사용자 이름 변경
public User update(String nickname) {
this.nickname = nickname;
return this;
}
}
◆ 리포지토리 만들기
User 엔티티에 대한 리포지토리를 만들기 위해 repository 디렉토리에 UserRepository.java 파일을 생성하고 인터페이스 생성
import com.example.spro01.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); // email로 사용자 정보 가져옴
}
◆ 서비스 메소드 코드 작성
스프링 시큐리티에서 로그인을 진행할 때 사용자 정보를 가져오는 코드 작성
service 디렉토리에 UserDetailsService.java 파일을 생성하고 코드 작성
import com.example.spro01.domain.User;
import com.example.spro01.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
// 스프링 시큐리티에서 사용자 정보를 가져오는 인터페이스
public class UserDetailService implements UserDetailsService{
private final UserRepository userRepository;
// 사용자 이름(email)으로 사용자의 정보를 가져오는 메소드
@Override
public User loadUserByUsername(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException((email)));
}
}
◆ 시큐리티 설정
실제 인증 처리를 하는 시큐리티 설정 파일 WebSecurityConfig.java 을 패키지 내에 config 패키지를 만들어 해당 패키지 내에 생성한다.
import com.example.spro01.service.UserDetailService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import static org.springframework.boot.autoconfigure.security.servlet.PathRequest.toH2Console;
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class WebSecurityConfig {
private final UserDetailService userService;
// 스프링 시큐리티 기능 비활성화
@Bean
public WebSecurityCustomizer configure() {
return (web) -> web.ignoring()
.requestMatchers(toH2Console())
.requestMatchers("/static/**");
}
// 특정 HTTP 요청에 대한 웹 기반 보안 구성
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/login", "/signup", "/user").permitAll()
.anyRequest().authenticated()
)
.formLogin(formLogin ->
formLogin
.loginPage("/login")
.defaultSuccessUrl("/articles", true)
)
.logout(logout ->
logout
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
) // CSRF 보호 활성화
.build();
}
// 인증 관리자 관련 설정
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailService userDetailService) throws Exception {
return http.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(userService) // 사용자 정보 서비스 설정
.passwordEncoder(bCryptPasswordEncoder)
.and()
.build();
}
// 패스워드 인코더로 사용할 빈 등록
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
회원 가입, 로그아웃 구현하기
◆ 회원 가입 구현
회원정보를 추가하는 서비스 메소드를 작성하고, 회원 가입 컨트롤러를 구현한다.
서비스 메소드는 dto 디렉토리에 AddUserRequest.java 파일을 추가하고 코드를 작성한다.
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AddUserRequest {
private String email;
private String password;
}
AddUserRequest 객체를 인수로 받는 회원 정보 추가 메소드 작성을 위해, service 디렉토리에 UserService.java 파일을 생성하고 코드를 작성한다.
findById() 메소드는 전달받은 유저 ID로 유저를 전달하는 메소드이며, 추후 리프레시 토큰을 전달받아 토큰 제공자를 사용해 새로운 액세스 토큰을 만드는 토큰 서비스 클래스 생성 시 필요하다.
또한, 추후 OAuth2 구현 시에 필요한 findByEmail() 메소드를 추가한다.
import com.example.spro01.domain.User;
import com.example.spro01.dto.AddUserRequest;
import com.example.spro01.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
// private final BCryptPasswordEncoder bCryptPasswordEncoder;
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 id) {
return userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
public User findByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
}
controller 디렉토리에 UserApiController.java 파일을 만들어 코드 작성
import com.example.spro01.dto.AddUserRequest;
import com.example.spro01.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@RequiredArgsConstructor
@Controller
public class UserApiContorller {
private final UserService userService;
@PostMapping("/user")
public String signup(AddUserRequest request) {
userService.save(request); // 회원 가입 메소드 호출
return "redirect:/login"; // 회원 가입 완료된 이후에 로그인 페이지로 이동
}
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login";
}
}
회원가입, 로그인 뷰 작성
로그인, 회원 가입 경로로 접근하면 뷰 파일을 연결하는 컨트롤러를 생성하기 위해 controller 디렉토리에 UserViewController.java 파일을 만들고 코드를 작성한다.
oauthLogin의 경우, 추후 OAuth2 로그인 서비스 구현 시 필요하므로 미리 지정한다.
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";
}
}
뷰를 만들기 위해 templates 디렉토리에 login.html 을 생성해 코드 작성
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>로그인</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">
<style>
.gradient-custom {
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">
<form action="/login" method="POST">
<input type="hidden" th:name="${_csrf?.parameterName}" th:value="${_csrf?.token}" />
<div class="mb-3">
<label class="form-label text-white">Email address</label>
<input type="email" class="form-control" name="username">
</div>
<div class="mb-3">
<label class="form-label text-white">Password</label>
<input type="password" class="form-control" name="password">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
<button type="button" class="btn btn-secondary mt-3" onclick="location.href='/signup'">회원가입</button>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
JWT 서비스 구현하기
◆ 의존성 추가하기
build.gradle에 필요한 의존성을 추가하고 새로고침
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'
◆ application.properties에 이슈 발행자, 비밀키 설정
jwt.issuer=발행자 정보
jwt.secret-key=비밀키
◆ JwtProperties 클래스 생성
이슈 발행자, 비밀키 값들을 변수로 접근하는 데 사용할 JwtProperties 클래스를 confit/jwt 패키지에 JwtProperties.java 파일을 만들어 코드 작성
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Getter
@Setter
@Component
@ConfigurationProperties("jwt") // 자바 클래스에 프로피티 값 가져와서 사용하는 애너테이션
public class JwtProperties {
private String issuer;
private String secretKey;
}
◆ TokenProvider 클래스 생성
토큰을 생성하고 올바른 토큰인지 유효성 검사를 하고, 토큰에서 필요한 정보를 가져오는 TokenProvider 클래스를 config/jwt 디렉토리에 TokenProvider.java 파일을 생성해 코드를 작성한다.
import com.example.spro01.domain.User;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.Set;
@RequiredArgsConstructor
@Service
public class TokenProvider {
private final JwtProperties jwtProperties;
public String generateToken(User user, Duration duration) {
// null 검사
if (user == null || user.getEmail() == null || user.getId() == null) {
throw new IllegalArgumentException("User or its properties cannot be null");
}
if (jwtProperties.getIssuer() == null || jwtProperties.getSecretKey() == null) {
throw new IllegalArgumentException("JWT issuer or secret key cannot be null");
}
if (duration == null || duration.isNegative() || duration.isZero()) {
throw new IllegalArgumentException("Duration must be positive");
}
Date now = new Date();
Date expiryDate = new Date(now.getTime() + duration.toMillis());
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(now)
.setExpiration(expiryDate)
.setSubject(user.getEmail())
.claim("id", user.getId())
.claim("provider", user.getProvider())
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
.compact();
}
// JWT 토큰 유효성 검증 메소드
public boolean validateToken(String token) {
try {
Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token);
return true; // 유효한 토큰
} catch (ExpiredJwtException e) {
System.out.println("JWT token is expired: " + e.getMessage());
} catch (JwtException e) {
System.out.println("Invalid JWT token: " + e.getMessage());
} catch (Exception e) {
System.out.println("An error occurred: " + e.getMessage());
}
return false; // 비유효한 토큰
}
// 토큰 기반으로 인증 정보 가져오는 메소드
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
return new UsernamePasswordAuthenticationToken(new org.springframework.security.core.userdetails.User(claims.getSubject(),
"", authorities), token, authorities);
}
// 토큰 기반으로 유저 ID 가져오는 메소드
public Long getUserId(String token) {
Claims claims = getClaims(token);
return claims.get("id", Long.class);
}
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody();
}
}
TokenProvider 클래스 테스트
test/config.jwt 디렉토리에 TokenProviderTest.java 파일을 만들고 TokenProviderTest 클래스를 만들고 코드 작성
import com.example.spro01.repository.UserRepository;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import java.time.Duration;
import java.util.Date;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
public class TokenProviderTest {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private UserRepository userRepository;
@Autowired
private JwtProperties jwtProperties;
// generateToken() 검증 테스트
@DisplayName("generateToken(): 유저 정보와 만료 기간을 전달해 토큰 생성")
@Test
void generateToken() {
//given
User testUser = userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
//when
String token = tokenProvider.generateToken(testUser, Duration.ofDays(14));
//then
Long userId = Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody()
.get("id", Long.class);
assertThat(userId).isEqualTo(testUser.getId());
}
// validToken() 검증 테스트
@DisplayName("validToken(): 만료된 토큰일 경우 유효성 검증 실패")
@Test
void validToken_invalidToken() {
//given
String token = JwtFactory.builder()
.expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
.build()
.createToken(jwtProperties);
//when
boolean result = tokenProvider.validateToken(token);
//then
assertThat(result).isFalse();
}
@DisplayName("validToken(): 유효한 토큰인 경우 유효성 검증 성공")
@Test
void validToken_validToken() {
// given
String token = JwtFactory.withDefaultValues().createToken(jwtProperties);
// when
boolean result = tokenProvider.validateToken(token);
// then
assertThat(result).isTrue();
}
// getAuthentication() 검증 테스트
@DisplayName("getAuthentication(): 토큰 기반으로 인증 정보 가져오기")
@Test
void getAuthentication() {
//given
String userEmail = "user@email.com";
String token = JwtFactory.builder()
.subject(userEmail)
.build()
.createToken(jwtProperties);
//when
Authentication authentication = tokenProvider.getAuthentication(token);
//then
assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
}
// getUserId() 검증 테스트
@DisplayName("getUserId(): 토큰으로 유저 ID 가져오기")
@Test
void getUserId() {
//given
Long userId = 1L;
String token = JwtFactory.builder()
.claims(Map.of("id", userId))
.build()
.createToken(jwtProperties);
System.out.println("token:"+ token);
//when
Long userIdByToken = tokenProvider.getUserId(token);
System.out.println("userIdByToken:" + userIdByToken);
//then
assertThat(userIdByToken).isEqualTo(userId);
}
}
테스트를 실행해본 결과 잘 통과된 것을 확인할 수 있다.
리프레시 토큰 도메인 구현하기
리프레시 토큰은 데이터베이스에 저장하는 정보이므로 엔터티와 리포지토리를 추가해야 한다.
[생성 엔티티와 매핑되는 테이블 구조]
컬럼명 | 자료형 | null 허용 | 키 | 설명 |
id | BIGINT | N | PK | 일련번호_기본키 |
user_id | BIGINT | N | 유저 ID | |
refresh_token | VARCHAR(255) | N | 토큰값 |
domain 디렉토리에 RefreshToken.java 파일을 추가하여 코드 작성
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;
@Column(name = "refresh_token", nullable = false)
private String refreshToken;
public RefreshToken(Long userId, String refreshToken) {
this.userId = userId;
this.refreshToken = refreshToken;
}
public RefreshToken update(String newRefreshToken) {
this.refreshToken = newRefreshToken;
return this;
}
}
repository 디렉토리에 RefreshTokenRepository.java 파일을 만든 후 코드 작성
import com.example.spro01.domain.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUserId(Long userId);
Optional<RefreshToken> findByRefreshToken(String refreshToken);
}
토큰 필터 구현하기
필터는 실제로 각종 요청이 요청을 처리하기 위한 로직으로 전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능 제공.
요청이 오면 헤더값을 비교해서 토큰이 있는지 확인하고 유효 토큰인 경우 시큐리티 콘텍스트 홀더에 인증 정보를 저장한다.
시큐리티 컨텍스트는 인증 객체가 저장되는 보관소로, 인증 정보가 필요할 때 언제든지 인증 정보를 꺼내 사용할 수 있다.
이 클래스는 스레드 로컬에 저장되므로 코드의 아무 곳에서나 참조할 수 있고, 다른 스레드와 공유하지 않으므로 독립적으로 사용할 수 있다.
이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더이다.
config 디렉토리에 TokenAuthenticationFilter.java 파일을 만들고 코드를 작성한다.
이 필터는 액세스 토큰값이 담긴 Authorization 헤더값을 가져온 뒤 액세스 토큰이 유효하다면 인증 정보 설정.
import com.example.spro01.config.jwt.TokenProvider;
import jakarta.servlet.FilterChain;
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.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final static String AUTHORIZATION_HEADER = "Authorization";
private final static String TOKEN_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 요청 헤더의 Authorization 키의 값 조회
String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
// 가져온 값에서 접두사 제거
String token = getAccessToken(authorizationHeader);
// 가져온 토큰 유효 여부 확인 후, 유효할 시 인증 정보 설정
if (tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getAccessToken(String authorizationHeader) {
if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
return authorizationHeader.substring(TOKEN_PREFIX.length());
}
return null;
}
}
토큰 API 구현하기
service 디렉토리에 RefreshTokenService.java 파일을 새로 만들어 전달받은 리프레시 토큰으로 리프레시 토큰 객체를 검색해서 전달하는 findByRefreshToken() 메소드를 구현한다.
import com.example.spro01.domain.RefreshToken;
import com.example.spro01.repository.RefreshTokenRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
public RefreshToken findByRefreshToken(String refreshToken) {
return refreshTokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new IllegalArgumentException("not found: " + refreshToken));
}
}
그리고 service 디렉토리에 TokenService.java 파일을 생성해서 코드를 입력하는데,
createNewAccessToken() 메소드는 전달받은 리프레시 토큰으로 토큰 유효성 검사를 진행하고, 유효한 토큰일 때 리프레시 토큰으로 사용자 ID를 찾는다.
사용자 ID로 사용자를 찾은 후에 토큰 제공자의 generateToken() 메소드를 호출해서 새로운 액세스 토큰을 생성한다.
import com.example.spro01.config.jwt.TokenProvider;
import com.example.spro01.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
@RequiredArgsConstructor
@Service
public class TokenService {
private final TokenProvider tokenProvider;
private final RefreshTokenService refreshTokenService;
private final UserService userService;
public String createNewAccessToken(String refreshToken) {
// 토큰 유효성 검사에 실패하면 예외 발생
if(!tokenProvider.validateToken(refreshToken)) {
throw new IllegalArgumentException("Unexpected token");
}
Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
User user = userService.findById(userId);
// 액세스 토큰의 유효 기간을 2시간으로 직접 설정
return tokenProvider.generateToken(user, Duration.ofHours(2));
}
}
토큰을 생성하고, 유효성을 검증하는 로직 작성이 완료됐으니, 실제로 토큰을 발급받는 API를 생성한다.
dto 패키지에 CreateAccessTokenRequest와 CreateAccessTokenResponse 클래스를 만든다.
@Getter
@Setter
public class CreateAccessTokenRequest {
private String refreshToken;
}
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public class CreateAccessTokenResponse {
private String accessToken;
}
실제로 요청을 받고 처리할 컨트롤러를 생성한다.
controller 패키지에 TokenApiController.java 파일을 만들고 코드를 작성한다.
/api/token POST 요청이 오면 토큰 서비스에서 리프레시 토큰을 기반으로 새로운 액세스 토큰을 만들어준다.
import com.example.spro01.dto.CreateAccessTokenRequest;
import com.example.spro01.dto.CreateAccessTokenResponse;
import com.example.spro01.service.TokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController
public class TokenApiController {
private final TokenService tokenService;
@PostMapping("/api/token")
public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken(@RequestBody CreateAccessTokenRequest request) {
String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());
return ResponseEntity.status(HttpStatus.CREATED)
.body(new CreateAccessTokenResponse(newAccessToken));
}
}
테스트 코드를 만들어 실제로 어떻게 동작하는지 확인해 보기 위해, TokenApiController 에서 alt+enter를 눌러 테스트를 생성한다.
import com.example.spro01.config.jwt.JwtFactory;
import com.example.spro01.config.jwt.JwtProperties;
import com.example.spro01.domain.RefreshToken;
import com.example.spro01.domain.User;
import com.example.spro01.dto.CreateAccessTokenRequest;
import com.example.spro01.repository.RefreshTokenRepository;
import com.example.spro01.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.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.util.Map;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class TokenApiControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
private WebApplicationContext context;
@Autowired
JwtProperties jwtProperties;
@Autowired
UserRepository userRepository;
@Autowired
RefreshTokenRepository refreshTokenRepository;
@BeforeEach
public void mockMvcSetup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
userRepository.deleteAll();
}
@DisplayName("createNewAccessToken: 새로운 액세스 토큰을 발급한다.")
@Test
public void createNewAccessToken() throws Exception {
// given
final String url = "/api/token";
User testUser = userRepository.save(User.builder()
.email("user@gamil.com")
.password("test")
.build());
String refreshToken = JwtFactory.builder()
.claims(Map.of("id", testUser.getId()))
.build()
.createToken(jwtProperties);
refreshTokenRepository.save(new RefreshToken(testUser.getId(), refreshToken));
CreateAccessTokenRequest request = new CreateAccessTokenRequest();
request.setRefreshToken(refreshToken);
final String requestBody = objectMapper.writeValueAsString(request);
// when
ResultActions resultActions = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(requestBody));
// then
resultActions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accessToken").isNotEmpty());
}
}
OAuth2 로 소셜 로그인 구현하기
(구글, 카카오, 네이버)
OAuth2의 개념 및 각각의 소셜 로그인 구현 방법은 하단 링크들을 참고하면 된다.
[Java] Spring Boot: 스프링 시큐리티, OAuth2, JWT
이전 내용 [Java] Spring Boot: 네이버 로그인 구현하기이전 내용 [Java] Spring Boot: 카카오 로그인 기능 추가하기이전 내용 [Java] Spring Boot: 구글 로그인 기능 추가하기이전 내용 전반적인 순서1. Gradle 또
puppy-foot-it.tistory.com
[Java] Spring Boot: 구글 로그인 기능 추가하기
이전 내용 전반적인 순서1. Gradle 또는 Maven 의존성 추가2. 구글 API Console 설정 - 구글 개발자 콘솔 프로젝트 생성 → OAuth 2.0 클라이언트 ID 생성3. application.yaml 설정 (보안 문제로 해당 파일 .gitignore
puppy-foot-it.tistory.com
[Java] Spring Boot: 네이버 로그인 구현하기
이전 내용 [Java] Spring Boot: 카카오 로그인 기능 추가하기이전 내용 [Java] Spring Boot: 구글 로그인 기능 추가하기이전 내용 전반적인 순서1. Gradle 또는 Maven 의존성 추가2. 구글 API Console 설정 - 구글 개
puppy-foot-it.tistory.com
[Java] Spring Boot: 카카오 로그인 기능 추가하기
이전 내용 [Java] Spring Boot: 구글 로그인 기능 추가하기이전 내용 전반적인 순서1. Gradle 또는 Maven 의존성 추가2. 구글 API Console 설정 - 구글 개발자 콘솔 프로젝트 생성 → OAuth 2.0 클라이언트 ID 생성
puppy-foot-it.tistory.com
쿠키 관리 클래스를 구현하기 위해 util 패키지를 만들고 CookieUtil.java 파일을 생성한 뒤 코드를 작성한다.
이렇게하면 OAuth2 인증 플로우를 구현하며 쿠키를 사용할 일이 생기는데 그때마다 쿠키를 생성하고 삭제하는 로직을 추가하지 않고 유틸리티로 사용하게 된다.
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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);
}
}
}
private static final ObjectMapper objectMapper = new ObjectMapper();
// 객체를 직렬화해 쿠키의 값으로 변환
public static String serialize(Object obj) throws JsonProcessingException {
// 객체를 JSON 문자열로 직렬화 후 Base64로 인코딩하여 변환
String jsonString = objectMapper.writeValueAsString(obj);
return Base64.getUrlEncoder().encodeToString(jsonString.getBytes());
}
// 쿠키를 역직렬화해 객체로 변환
public static <T> T deserialize(Cookie cookie, Class<T> cls) throws Exception {
// 쿠키의 값을 Base64로 디코딩한 후 문자열로 변환
byte[] decodedBytes = Base64.getUrlDecoder().decode(cookie.getValue());
String jsonString = new String(decodedBytes);
// JSON 문자열을 객체로 역직렬화
return objectMapper.readValue(jsonString, cls);
}
}
config 패키지에 oauth 패키지를 만들고 OAuth2UserCustomService.java 파일을 생성한 메소드를 추가한다.
import com.example.spro01.domain.User;
import com.example.spro01.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 = null;
String nickname = "사용자"; // 기본값
// 카카오 및 네이버 로그인 처리
if (attributes.containsKey("kakao_account")) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
if (kakaoAccount != null) {
email = (String) kakaoAccount.get("email");
Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
if (profile != null) {
nickname = (String) profile.get("nickname"); // nickname으로 업데이트
}
}
} else if (attributes.containsKey("response")) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
email = (String) response.get("email");
nickname = (String) response.get("name");
} else {
email = (String) attributes.get("email");
nickname = (String) attributes.get("name");
}
// 이메일 및 사용자 이름 유효성 체크
if (email == null || email.isEmpty()) {
throw new IllegalArgumentException("이메일 정보가 없습니다. OAuth2 사용자: " + attributes);
}
if (nickname == null || nickname.isEmpty()) {
nickname = "사용자"; // 기본값을 재설정할 수 있습니다.
}
// 최종 변수로 만들어 람다에서 사용
final String finalName = nickname;
User user = userRepository.findByEmail(email)
.map(entity -> entity.update(finalName))
.orElse(User.builder()
.email(email)
.nickname(finalName)
.build());
return userRepository.save(user);
}
}
OAuth2와 JWT를 함께 사용하기 위해 기존 스프링 시큐리티 설정이 아닌 다른 설정을 사용해야 하므로, WebSecurityConfig.java 파일은 삭제 (또는 주석 처리)하고 config 패키지에 WebOAuthSecurityConfig.java 파일을 생성하고 코드를 작성한다.
import com.example.spro01.config.jwt.TokenProvider;
import com.example.spro01.config.oauth.OAuth2AuthorizationRequestBasedOnCookieRepository;
import com.example.spro01.config.oauth.OAuth2SuccessHandler;
import com.example.spro01.config.oauth.OAuth2UserCustomService;
import com.example.spro01.repository.RefreshTokenRepository;
import com.example.spro01.service.UserService;
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.annotation.web.configurers.AbstractHttpConfigurer;
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
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/login",
"/signup",
"/user",
"/oauth2/**" // OAuth2 로그인 관련 요청 허용
).permitAll() // 허용된 요청 URL 설정
.anyRequest().authenticated() // 나머지 요청은 인증 필요
)
.oauth2Login(oauth2Login -> oauth2Login
.loginPage("/login") // 사용자 정의 로그인 페이지
.defaultSuccessUrl("/articles") // 로그인 성공 시 리다이렉트 URL
.failureUrl("/login?error=true") // 로그인 실패 시 리다이렉트 URL
)
.logout(logout -> logout
.logoutSuccessUrl("/login") // 로그아웃 성공 시 리다이렉트 URL
.invalidateHttpSession(true) // 로그아웃 시 세션 무효화
)
.csrf(AbstractHttpConfigurer::disable);
return http.build(); // SecurityFilterChain 빌드하여 반환
}
@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();
}
}
그리고 OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소를 config/oauth 패키지에 OAuth2AuthorizationRequestBasedOnCookieRepository.java 파일을 생성하고,
권한 인증 흐름에서 클라이언트의 요청을 유지하는 데 사용하는 AuthorizationRequestRepository 클래스를 구현해 쿠키를 사용해 OAuth의 정보를 가져오고 저장하는 로직을 작성한다.
import com.example.spro01.util.CookieUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
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 removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
OAuth2AuthorizationRequest authorizationRequest = this.loadAuthorizationRequest(request);
this.removeAuthorizationRequestCookies(request, response);
return authorizationRequest;
}
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
if (cookie == null) {
return null; // 쿠키가 없으면 null 반환
}
try {
return CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class);
} catch (Exception e) {
// 적절한 예외 처리 로깅
// Log.error("Failed to deserialize OAuth2AuthorizationRequest", e);
e.printStackTrace();
return null;
}
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest,
HttpServletRequest request,
HttpServletResponse response) {
if (authorizationRequest == null) {
removeAuthorizationRequestCookies(request, response);
return;
}
try {
CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME,
CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
} catch (JsonProcessingException e) {
// 적절한 예외 처리 로깅
// Log.error("Failed to serialize OAuth2AuthorizationRequest", e);
e.printStackTrace();
}
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
}
}
config/oauth 패키지에 OAuth2SuccessHandler.java 파일을 생성해 인증 성공 시 실행할 핸들러를 구현한다.
import com.example.spro01.config.jwt.TokenProvider;
import com.example.spro01.domain.RefreshToken;
import com.example.spro01.domain.User;
import com.example.spro01.repository.RefreshTokenRepository;
import com.example.spro01.service.UserService;
import com.example.spro01.util.CookieUtil;
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;
import java.util.Map;
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
// 리프레시 토큰 쿠키의 이름
public static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";
// 리프레시 토큰 유효 기간 (14일)
public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14);
// 액세스 토큰의 유효 기간(1일)
public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1);
// 인증 성공 후 리다이렉트할 경로
public static final String REDIRECT_PATH = "/articles";
// JWT 토큰 생성을 위한 TokenProvider
private final TokenProvider tokenProvider;
// 리프레시 토큰을 저장하기 위한 리포지토리
private final RefreshTokenRepository refreshTokenRepository;
// OAuth2 인증 요청 관련 쿠키를 관리하는 리포지토리
private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
// 사용자 정보를 조회하기 위한 서비스
private final UserService userService;
// OAuth2 인증 성공 시 호출
@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
// 인증된 사용자의 OAuth2User 객체 가져오기 *principal: 인증된 사용자 정보를 가지고 있음
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
// 사용자 이메일 속성을 사용해 사용자 정보 조회
String email = extractEmail(oAuth2User);
if (email == null) {
throw new IllegalArgumentException("Email not found in OAuth2 user attributes");
}
User user = userService.findByEmail(email);
// 리프레시 토큰 생성 -> 저장 -> 쿠키에 저장
String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION); // 유효 기간 14일
saveRefreshToken(user.getId(), refreshToken);
addRefreshTokenToCookie(request, response, refreshToken);
// 액세스 토큰 생성 -> 패스에 액세스 토큰 추가
String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION); // 유효기간 1일
String targetUrl = getTargetUrl(accessToken);
// 인증 관련 설정값, 쿠키 제거
clearAuthenticationAttributes(request, response);
// 클라이언트를 지정된 URL로 리다이렉트
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
private String extractEmail(OAuth2User oAuth2User) {
// 카카오 계정에서 이메일 추출
if (oAuth2User.getAttributes().containsKey("kakao_account")) {
Map<String, Object> kakaoAccount = (Map<String, Object>) oAuth2User.getAttributes().get("kakao_account");
if (kakaoAccount != null && kakaoAccount.containsKey("email")) {
return (String) kakaoAccount.get("email"); // 이메일 값을 올바르게 반환
}
}
// 구글 계정에서 이메일 추출
if (oAuth2User.getAttributes().containsKey("email")) {
return (String) oAuth2User.getAttributes().get("email");
}
// 네이버 계정에서 이메일 추출
if (oAuth2User.getAttributes().containsKey("response")) {
Map<String, Object> responseAttributes = (Map<String, Object>) oAuth2User.getAttributes().get("response");
return (String) responseAttributes.get("email");
}
return null; // 이메일을 찾을 수 없는 경우 null 반환
}
// 생성된 리프레시 토큰을 전달받아 데이터베이스에 저장
private void saveRefreshToken(Long userId, String newRefreshToken) {
RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
// 기존 토큰 있으면 업데이트
.map(entity -> entity.update(newRefreshToken))
// 없으면 새 RefreshToken 객체 생성
.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();
}
}
기존 글을 작성하는 API에서 작성자를 추가로 저장하기 위해 dto/AddArticlesRequest.java 파일을 열고 author 값도 추가 저장하도록 변경한다.
import com.example.spro01.domain.Article;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AddArticleRequest {
private String title;
private String content;
private String author;
// title과 content만 받는 생성자를 추가
public AddArticleRequest(String title, String content) {
this.title = title;
this.content = content;
}
public Article toEntity(String author) { //DTO를 엔티티로 만들어주는 메소드
return Article.builder()
.title(title)
.content(content)
.author(author)
.build();
}
}
service 패키지의 BlogService.java 파일을 열고 sava() 메소드를 유저 이름을 추가로 입력받고 toEntity() 인수로 전달받은 유저 이름을 반환하도록 코드를 수정한다.
import com.example.spro01.domain.Article;
import com.example.spro01.dto.AddArticleRequest;
import com.example.spro01.dto.UpdateArticleRequest;
import com.example.spro01.repository.BlogRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@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));
authorizedArticleAuthor(article);
blogRepository.delete(article);
}
@Transactional // 트랜잭션 메소드
public Article update(long id, UpdateArticleRequest request) {
Article article = blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found: " + id));
authorizedArticleAuthor(article);
article.update(request.getTitle(), request.getContent());
return article;
}
// 게시글을 작성한 유저인지 확인
private static void authorizedArticleAuthor(Article article) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 인증 정보가 없는 경우 예외 처리
if (authentication == null || !authentication.isAuthenticated()) {
throw new SecurityException("Authentication is required.");
}
String userName = authentication.getName();
// 아티클 저자와 현재 사용자 이름 비교
if (!article.getAuthor().equals(userName)) {
throw new AccessDeniedException("Not authorized: You do not own this article.");
}
}
}
controller 패키지의 BlogApiController.java 파일을 열고 현재 인증 정보를 가져오는 principal 객체를 파라미터로 추가한다.
인증 객체에서 유저 이름을 가져온 뒤 save() 메서드로 넘겨준다.
import com.example.spro01.domain.Article;
import com.example.spro01.dto.AddArticleRequest;
import com.example.spro01.dto.ArticleResponse;
import com.example.spro01.dto.UpdateArticleRequest;
import com.example.spro01.service.BlogService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.ArrayList;
import java.util.List;
@RequiredArgsConstructor
@RestController // HTTP Response Body에 객체 데이터를 JSON 형식으로 반환
public class BlogApiController {
private final BlogService blogService;
// HTTP 메소드가 POST 일때 전달받은 URL과 동일하면 메소드로 매핑
@PostMapping("/api/articles")
public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request, Principal principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Article savedArticle = blogService.save(request, principal.getName());
return ResponseEntity.status(HttpStatus.CREATED)
.body(savedArticle);
}
// 모든 블로그 글 조회 메소드
@GetMapping("/api/articles")
public ResponseEntity<List<ArticleResponse>> findAllArticles() {
List<ArticleResponse> articles = blogService.findAll()
.stream()
.map(ArticleResponse::new)
.toList();
return ResponseEntity.ok(articles);
}
@GetMapping("/api/articles/{id}")
// url 경로에서 값 추출
public ResponseEntity<ArticleResponse> findArticle(@PathVariable long id) {
Article article = blogService.findById(id);
return ResponseEntity.ok().body(new ArticleResponse(article));
}
@DeleteMapping("/api/articles/{id}")
public ResponseEntity<Void> deleteArticle(@PathVariable long id) {
blogService.delete(id);
return ResponseEntity.ok()
.build();
}
// 특정 ID의 블로그 글 업데이트
@PutMapping("/api/articles/{id}")
public ResponseEntity<Article> updateArticle(@PathVariable long id,
@RequestBody UpdateArticleRequest request) {
Article updatedArticle = blogService.update(id, request);
return ResponseEntity.ok()
.body(updatedArticle);
}
}
로그인 화면에 OAuth 연결 버튼을 생성하기 위해
resources/templates 디렉토리에 oauthLogin.html 파일을 생성하고 코드를 작성한다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">
<style>
.gradient-custom {
background: #6a11cb;
background: -webkit-linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1));
background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))
}
</style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
<div class="container-fluid row justify-content-center align-content-center">
<div class="card bg-dark" style="border-radius: 1rem;">
<div class="card-body p-5 text-center">
<h2 class="text-white">LOGIN</h2>
<p class="text-white-50 mt-2 mb-5">서비스 사용을 위해 로그인을 해주세요!</p>
<div class = "mb-2">
<a href="/oauth2/authorization/google">
<img src="/img/google.png">
</a>
</div>
<div class = "mb-2">
<a href="/oauth2/authorization/kakao">
<img src="/img/kakao.png">
</a>
</div>
<div class = "mb-2">
<a href="/oauth2/authorization/naver">
<img src="/img/naver.png">
</a>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
이 HTML 파일과 연결할 자바스크립트 파일을 만들기 위해
resources/js 디렉토리에 token.js 파일을 만들고 코드를 작성한다.
const token = searchParam('token');
if (token) {
localStorage.setItem('access_token', token)
}
function searchParam(key) {
return new URLSearchParams(location.search).get(key);
}
그리고 같은 디렉토리에 있는 article.js 파일을 열어(없다면 생성) 코드를 작성한다.
// 삭제 기능
const deleteButton = document.getElementById('delete-btn');
if (deleteButton) {
deleteButton.addEventListener('click', event => {
let id = document.getElementById('article-id').value;
function success() {
alert('삭제가 완료되었습니다.');
location.replace('/articles');
}
function fail() {
alert('삭제 실패했습니다.');
location.replace('/articles');
}
httpRequest('DELETE',`/api/articles/${id}`, null, success, fail);
});
}
// 수정 기능
const modifyButton = document.getElementById('modify-btn');
if (modifyButton) {
modifyButton.addEventListener('click', event => {
let params = new URLSearchParams(location.search);
let id = params.get('id');
body = JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
})
function success() {
alert('수정 완료되었습니다.');
location.replace(`/articles/${id}`);
}
function fail() {
alert('수정 실패했습니다.');
location.replace(`/articles/${id}`);
}
httpRequest('PUT',`/api/articles/${id}`, body, success, fail);
});
}
// 생성 기능
const createButton = document.getElementById('create-btn');
if (createButton) {
// 등록 버튼을 클릭하면 /api/articles로 요청을 보낸다
createButton.addEventListener('click', event => {
body = JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
});
function success() {
alert('등록 완료되었습니다.');
location.replace('/articles');
};
function fail() {
alert('등록 실패했습니다.');
location.replace('/articles');
};
httpRequest('POST','/api/articles', body, success, fail)
});
}
// 쿠키를 가져오는 함수
function getCookie(key) {
var result = null;
var cookie = document.cookie.split(';');
cookie.some(function (item) {
item = item.replace(' ', '');
var dic = item.split('=');
if (key === dic[0]) {
result = dic[1];
return true;
}
});
return result;
}
// HTTP 요청을 보내는 함수
function httpRequest(method, url, body, success, fail) {
fetch(url, {
method: method,
headers: { // 로컬 스토리지에서 액세스 토큰 값을 가져와 헤더에 추가
Authorization: 'Bearer ' + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
},
body: body,
}).then(response => {
if (response.status === 200 || response.status === 201) {
return success();
}
const refresh_token = getCookie('refresh_token');
if (response.status === 401 && refresh_token) {
fetch('/api/token', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
},
body: JSON.stringify({
refreshToken: getCookie('refresh_token'),
}),
})
.then(res => {
if (res.ok) {
return res.json();
}
})
.then(result => { // 재발급이 성공하면 로컬 스토리지값을 새로운 액세스 토큰으로 교체
localStorage.setItem('access_token', result.accessToken);
httpRequest(method, url, body, success, fail);
})
.catch(error => fail());
} else {
return fail();
}
});
}
블로그 기능 구현 확인
localhost:포트번호
를 접속하면 로그인 페이지(/login)가 뜬다.
소셜 로그인도 잘 된다.
등록 기능 구현 성공
조회 기능 구현 성공
수정 기능 구현 성공
삭제 기능 구현 성공
[참고]
https://cookiee.tistory.com/683
https://hoehen-flug.tistory.com/24
https://devlog-wjdrbs96.tistory.com/204
https://programmer93.tistory.com/68
스프링 부트3 백엔드 개발자 되기
https://github.com/shinsunyoung/springboot-developer/issues/5
다음 내용
[Java] Spring Boot: 외부 파일 열기
이전 내용 [Java] Spring Boot: 블로그 앱 만들기이전 내용 [Java] Spring Boot: 스프링 시큐리티, OAuth2, JWT이전 내용 [Java] Spring Boot: 네이버 로그인 구현하기이전 내용 [Java] Spring Boot: 카카오 로그인 기능 추
puppy-foot-it.tistory.com
'[Java] > Spring Boot' 카테고리의 다른 글
[Java] Spring Boot: 깃허브 대용량 (인텔리제이 깃허브 연동) (0) | 2025.04.22 |
---|---|
[Java] Spring Boot: 외부 파일 열기 (0) | 2025.04.22 |
[Java] Spring Boot: 스프링 시큐리티, OAuth2, JWT (0) | 2025.04.21 |
[Java] Spring Boot: 네이버 로그인 구현하기 (0) | 2025.04.21 |
[Java] Spring Boot: 카카오 로그인 기능 추가하기 (1) | 2025.04.21 |