이전 포스트에서 토큰의 저장 위치에 대해 알아보았습니다.
이번에는 저희가 만든 프로젝트에서 어떻게 로그인 및 인증 로직이 수행되는지 살펴보도록 하겠습니다.
프론트엔드 코드를 설명하기에 앞서 백엔드에서 사용하는 로그인 / 토큰 갱신 API를 살펴보겠습니다.
Backend Code ( Spring Boot )
쿠키 관련 유틸 함수
private Cookie createRefreshTokenCookie(TokenResponse tokenResponse) {
Cookie cookie = new Cookie("rtk", tokenResponse.getRtk());
cookie.setHttpOnly(true);
//cookie.setSecure(true); // Https 사용 시
cookie.setPath("/");
Date now = new Date();
int age = (int) (tokenResponse.getRtkExpiration().getTime() - now.getTime()) / 1000;
cookie.setMaxAge(age);
return cookie;
}
private String getRefreshTokenFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null; // 또는 적절한 예외 처리
}
for (Cookie cookie : cookies) {
if (cookie.getName().equals("rtk")) {
return cookie.getValue();
}
}
return null;
}
private Cookie createDeleteCookie() {
// 쿠키 삭제
Cookie cookie = new Cookie("rtk", null); // 쿠키 이름과 값을 설정
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(0); // 쿠키 만료 시간 설정 (0으로 설정하면 쿠키가 삭제됨)
return cookie;
}
로그인 API
@PostMapping("/api/v1/auth/signintest")
public ResultResponse<AccessTokenResponse> singInTest(@RequestBody SignInRequest request, HttpServletResponse response) throws JsonProcessingException {
if(employeeService.isValidEmailAndPassword(request)) {
TokenResponse tokenResponse = jwtProvider.createTokensBySignIn(request.getEmail());
Cookie cookie = createRefreshTokenCookie(tokenResponse);
response.addCookie(cookie);
return new ResultResponse<>(HttpStatus.OK.value(), "success", AccessTokenResponse.builder()
.atk(tokenResponse.getAtk())
.atkExpiration(tokenResponse.getAtkExpiration())
.build());
} else {
return new ResultResponse<>(HttpStatus.BAD_REQUEST.value(), "fail", null);
}
}
사용자가 이메일과 패스워드를 Json에 담아 해당 API에 요청할 경우, Access Token은 Json에 담아서, Refresh Token은 HttpOnly Cookie에 담아 반환합니다. ( Json이 아니라 Html Form에 Access Token을 담아야 CSRF 공격으로부터 안전하다고 합니다,..... 추 후 수정하겠습니다. )
토큰 갱신 API
@GetMapping("/api/v1/auth/renew")
public ResultResponse<AccessTokenResponse> reNew(HttpServletRequest request, HttpServletResponse response) throws JsonProcessingException {
String rtk = getRefreshTokenFromCookie(request);
if (rtk == null) {
return new ResultResponse<>(HttpStatus.UNAUTHORIZED.value(), "fail", null);
}
TokenResponse tokenResponse = jwtProvider.renewToken(rtk);
Cookie cookie = createRefreshTokenCookie(tokenResponse);
response.addCookie(cookie);
return new ResultResponse<>(HttpStatus.OK.value(), "success", AccessTokenResponse.builder()
.atk(tokenResponse.getAtk())
.atkExpiration(tokenResponse.getAtkExpiration())
.build());
}
로그아웃 API
@GetMapping("/api/v1/auth/logout")
public ResultResponse<Void> logout(HttpServletRequest request, HttpServletResponse response) throws JsonProcessingException {
String rtk = getRefreshTokenFromCookie(request);
if (rtk == null) {
return new ResultResponse<>(HttpStatus.BAD_REQUEST.value(), "fail", null);
}
response.addCookie(createDeleteCookie());
employeeService.logout(rtk);
return new ResultResponse<>(HttpStatus.OK.value(), "success", null);
}
Frontend Code (Vue3 & Pinia)
Access Token 관리를 위한 상태관리 코드 ( store )
앞서 Access Token을 프론트엔드 어플리케이션의 변수(메모리)에 저장한다고 하였습니다.
따라서 여러 컴포넌트에서 이 토큰을 사용하기 위해 상태관리 툴인 Pinia를 사용해보도록 하겠습니다.
Pinia에 대한 내용은 공식 문서를 참조해주세요!! (되게 설명 잘되어있음)
src/store/token-store.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useTokenStore = defineStore("token", () => {
// state
const atk = ref("");
const atkExpiration = ref("");
const rtkExpiration = ref("");
// getters
const getAtk = computed(() => atk.value);
// actions
function setAtk(token) {
atk.value = token;
}
function setSigninResponse(atkParam, atkExpirationParam) {
atk.value = atkParam;
atkExpiration.value = new Date(atkExpirationParam);
}
return {
atk,
atkExpiration,
rtkExpiration,
setAtk,
getAtk,
setSigninResponse,
};
});
이제 우리는 프론트엔드 어플리케이션 전역에서 사용할 수 있는 store(자바의 클래스와 비슷합니다!)를 만들었습니다.
따라서 다른 컴포넌트에서 해당 store를 불러와 전역 변수(Access Token, 만료 시간)을 사용할 수 있습니다.
다음으로 로그인 컴포넌트의 스크립트 부분을 살펴보도록 하겠습니다.
src/pages/auth/SignInPage.vue ( Script )
<script setup>
import { ref } from "vue";
import axios from "axios";
import { useRouter } from "vue-router";
import { useTokenStore } from "stores/token-store";
const store = useTokenStore();
const email = ref("");
const password = ref("");
const router = useRouter();
const signIn = async () => {
try {
const response = await axios.post(
"http://localhost:8080/api/v1/auth/signintest",
{
email: email.value,
password: password.value,
}
);
// 메모리에 atk, atk 만료시간, rtk 만료시간 저장
store.setSigninResponse(
response.data.data.atk,
response.data.data.atkExpiration
);
router.push("/");
} catch (error) {
console.log(error);
}
};
</script>
위 코드에서 signIn 함수는 로그인 하기 버튼을 누를 때 실행되는 함수입니다.
import { useTokenStore } from "stores/token-store"; 를 사용해 store를 불러오고, store.setSigninResponse를 사용해 반환 받은 Access Token을 상태관리 변수에 저장하였습니다.
로그인을 한 번 해보겠습니다!
Response
Cookies
정상적으로 수행되는 것을 확인할 수 있습니다.
이번에는 최상위 컴포넌트인 MainLayout.vue를 살펴보겠습니다.
상단 툴 바에 로그인이 된 경우 로그아웃 버튼이 나타나도록 해봅시다.
src/layouts/MainLayout.vue
template
<template>
<q-layout view="hHh lpR lff">
<q-header elevated class="bg-primary text-white">
<q-toolbar>
<q-btn
flat
round
dense
icon="menu"
class="q-mr-sm"
@click="toggleLeftDrawer"
/>
<q-avatar>
<q-img
src="https://github.com/kinggodgeneralteam2/TEAM2-MINGLE-CRM/assets/155680893/d2c27cc2-d62e-4459-9e66-c46426da8fac"
/>
</q-avatar>
<q-toolbar-title shrink class="text-subtitle1 text-weight-bolder">
Mingle CRM
</q-toolbar-title>
<q-space />
<div class="search row items-center"></div>
<q-space />
<div v-if="atk">
<q-btn
outline
rounded
color="accent"
icon="account_circle"
label="로그아웃 "
to="/"
@click="logout"
/>
<q-btn
outline
rounded
color="accent"
icon="account_circle"
label="마이페이지"
to="/mypage"
/>
</div>
<div v-else>
<q-btn
outline
rounded
color="accent"
icon="account_circle"
label="회원가입 "
href="#/signup"
/>
<q-btn
outline
rounded
color="accent"
icon="account_circle"
label="로그인"
href="#/signin"
/>
</div>
</q-toolbar>
</q-header>
<q-drawer show-if-above v-model="leftDrawerOpen" side="left" bordered>
<q-list>
<EssentialLink
v-for="link in linksList"
:key="link.title"
v-bind="link"
/>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
중간에 v-if 문법을 사용하여 만약 atk가 존재할 경우(로그인 된 경우) 로그아웃 버튼과 마이페이지를 툴 바에 나타내도록 하였습니다.
스크립트를 봅시다
<script>
<script setup>
import { ref, watch, onMounted } from "vue";
import EssentialLink from "components/EssentialLink.vue";
import { useTokenStore } from "src/stores/token-store";
import { storeToRefs } from "pinia";
import axios from "axios";
const store = useTokenStore();
const { atk } = storeToRefs(store);
const linksList = [
{
title: "고객",
caption: "고객 탭",
icon: "school",
to: "/customer",
},
{
title: "리뷰",
caption: "리뷰 탭",
icon: "school",
to: "/review",
},
{
title: "바우처",
caption: "바우처 탭",
icon: "school",
to: "/voucher",
},
{
title: "상담",
caption: "상담 탭",
icon: "school",
to: "/inquiry",
},
];
const logout = async () => {
try {
console.log("로그아웃");
const response = await axios.get(
"http://localhost:8080/api/v1/auth/logout",
{
withCredentials: true,
}
);
console.log(response.status);
store.setAtk("");
atkExpiration = "";
} catch (error) {
console.log(error);
}
};
const renewToken = async () => {
try {
const response = await axios.get(
"http://localhost:8080/api/v1/auth/renew",
{
withCredentials: true,
}
);
if (response.status === 200) {
const { atk, atkExpiration } = response.data.data;
store.setSigninResponse(atk, atkExpiration);
console.log("갱신 완료");
} else {
throw new Error("Token renewal failed");
}
} catch (error) {
console.log(error);
console.log("토큰 갱신 실패 -> 로그아웃 상태");
window.location.href = "/#/signin";
return Promise.reject(error);
}
};
const leftDrawerOpen = ref(false);
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
//
}
onMounted(renewToken);
</script>
코드를 조금 살펴보겠습니다.
storeToRefs()
import { useTokenStore } from "src/stores/token-store";
import { storeToRefs } from "pinia";
const store = useTokenStore();
const { atk } = storeToRefs(store);
앞서 만든 TokenStore에 저장되어있는 변수들을 다른 컴포넌트에서 사용하기 위해서는 가장 먼저 import를 해주어야 합니다.
그 다음 pinia에서 제공하는 storeToRefs() 함수를 사용하여 store에 저장된 state(변수), getter(get 함수)들을 구조분해할당과 유사한 방식으로 선언해주어야 합니다.
만약 storeToRefs()가 아닌 직접 호출로 변수를 컴포넌트 script 파일에 선언할 경우 해당 컴포넌트에서 변수 값이 변할 경우 이를 전역에서 감지할 수 없습니다.
logout()
const logout = async () => {
try {
console.log("로그아웃");
const response = await axios.get(
"http://localhost:8080/api/v1/auth/logout",
{
withCredentials: true,
}
);
console.log(response.status);
store.setAtk("");
atkExpiration = "";
} catch (error) {
console.log(error);
}
};
axios를 사용하여 로그아웃을 수행합니다. axios.get() 함수의 두 번째 파라미터를 보면 withCredentials 옵션을 true로 설정해준 것을 알 수 있습니다. 해당 옵션을 true로 설정해주어야 요청에 쿠키가 담겨 보내지게 됩니다.
백엔드 어플리케이션의 logout api는 쿠키에서 토큰을 추출하므로 해당 옵션을 true로 해주었습니다.
새로고침 또는 탭 이동 시 Access Token 갱신
const renewToken = async () => {
try {
const response = await axios.get(
"http://localhost:8080/api/v1/auth/renew",
{
withCredentials: true,
}
);
if (response.status === 200) {
const { atk, atkExpiration } = response.data.data;
store.setSigninResponse(atk, atkExpiration);
console.log("갱신 완료");
} else {
throw new Error("Token renewal failed");
}
} catch (error) {
console.log(error);
console.log("토큰 갱신 실패 -> 로그아웃 상태");
window.location.href = "/#/signin";
return Promise.reject(error);
}
};
onMounted(renewToken);
모두가 아시는 것 처럼 onMounted() 함수는 컴포넌트가 최초 생성될 경우 실행됩니다.
하나 찝찝한 점은 로그인을 아직 하지 않은 경우에도 해당 api 요청이 실행된다는 점인데 이는 추후 Local Storage에 로그인 여부 정도만 추가하여 해결하도록 하겠습니다.
아무튼 새로고침 또는 탭 이동 시에도 쿠키에 담긴 Refresh Token을 사용해 Access Token을 계속 유지할 수 있도록 하였습니다.
만약 Refresh Token의 기간이 만료될 경우 백엔드 서버에서 이를 처리하여 에러 상태 코드를 반환할 것입니다. 이 경우에는 시간이 오래 지나 로그인이 풀린 경우거나 로그아웃을 수행한 경우 이므로 다시 로그인 페이지로 이동하도록 설정하였습니다.
전역 Axios 설정하기
이제 사용자 인증/인가에 관한 이야기를 해보겠습니다.
아시는 것 처럼 백엔드 서버에서 사용자의 로그인(인증)과 권한(인가) 여부를 토큰을 사용해 판별합니다.
(저희 백엔드 어플리케이션은 인증 및 인가 과정을 Spring Security의 Filter 수준에서 처리하였습니다.)
프론트엔드 어플리케이션에서 백엔드 어플리케이션으로 권한이 필요한 요청을 보내야 할 경우 http request 헤더 부분에 토큰을 담아 주어야 합니다.
Authorization: Bearer "Access Token"
따라서 프론트엔드 어플리케이션에서 Axios를 사용해 백엔드 서버의 API를 요청할 경우 헤더에 토큰을 담는 작업이 필요합니다.
수많은 Api 요청 함수들마다 직접 토큰을 담는 작업은 너무 불편합니다. 따라서 이를 한 번에 할 수 있도록 전역 Axios를 하나 만들어 미리 설정해놓겠습니다.
전역 Axios에는 다음과 같이 두 로직이 포함되어있습니다.
- Authorization 헤더에 상태관리중인 Access Token 담기
- 요청 전 Access Token 유효성 확인(존재하는지, 만료 되었는지) 후 쿠키에 담긴 Refresh Token을 사용해 갱신
- 만약 Refresh Token 또한 만료될 경우 로그인 페이지로 이동하도록 설정
- Refresh Token은 HttpOnly 옵션으로 인해 자바스크립트 코드로 접근이 불가능합니다.
src/boot/axios.js
import { boot } from "quasar/wrappers";
import axios from "axios";
import { useTokenStore } from "src/stores/token-store";
// Create an axios instance
const api = axios.create({ baseURL: "http://localhost:8080" });
export default boot(({ app }) => {
const store = useTokenStore();
api.interceptors.request.use(
async (config) => {
const atk = store.atk;
const atkExpiration = store.atkExpiration;
console.log("전역 axios interceptors 시작 ");
console.log("요청 전 atk : ", atk);
if (config.url.includes("/api/v1/auth/renew")) {
return config;
}
// Check if token is expired or not available
if (!atk || new Date(atkExpiration) <= new Date()) {
try {
console.log("토큰 갱신 요청");
const response = await api.get("/api/v1/auth/renew", {
withCredentials: true,
});
console.log("요청 보냄");
if (response.status === 200) {
const { atk, atkExpiration, rtkExpiration } = response.data.data;
store.setSigninResponse(atk, atkExpiration, rtkExpiration);
// Update the config's Authorization header with the new token
config.headers.Authorization = `Bearer ${atk}`;
} else {
throw new Error("Token renewal failed");
}
} catch (error) {
console.log("토큰 갱신 실패 -> 로그아웃 상태");
window.location.href = "/signin";
return Promise.reject(error);
}
} else {
// If token is valid, set it to the request's Authorization header
config.headers.Authorization = `Bearer ${atk}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
app.config.globalProperties.$axios = axios;
app.config.globalProperties.$api = api;
});
export { api };
이제 저희는 커스텀한 전역 Axios를 사용할 수 있게 되었습니다.
어떻게 사용하는지 ReviewPage를 예시로 사용해보겠읍니다!
예시
src/pages/ReviewPage.vue
<script>
<script setup>
import { ref, watch, onMounted } from "vue";
import axios from "src/boot/axios";
// import axios from "axios"; // axios 모듈을 기본 내보내기로 임포트
const current = ref(1);
const reviews = ref([]);
const hotel = ref("선택 안함");
const hotelOptions = ref(["선택 안함", "grand hotel", "super hotel"]);
const startDate = ref("");
const endDate = ref("");
const customerName = ref("");
const getHotelReviews = async () => {
try {
const searchCondition = ref({});
if (hotel.value !== "선택 안함") {
searchCondition.value.hotel = hotel.value;
}
if (startDate.value !== "" && endDate.value !== "") {
searchCondition.value.startDate = startDate.value;
searchCondition.value.endDate = endDate.value;
}
if (customerName.value !== "") {
searchCondition.value.customerName = customerName;
}
const response = await axios.post(
`http://localhost:8080/api/hotel/reviews/${current.value - 1}`,
searchCondition.value,
{ withCredentials: true }
);
reviews.value = response.data.data;
} catch (error) {
console.log(error);
}
};
// 페이지네이션 값이 변경될 때마다 getHotelReviews 함수 호출
watch(current, () => {
getHotelReviews();
});
// 컴포넌트가 마운트될 때 getHotelReviews 함수 호출
onMounted(() => {
getHotelReviews();
});
</script>
getHotelReviews() : DB에 저장된 호텔 리뷰 정보를 가져오는 api입니다.
axios를 import한 부분을 보면 조금 다른걸 알 수 있습니다.
기존에는 아래와 같이 axios를 import하였습니다.
import axios from 'axios';
인증/인가를 위해 token을 담고 갱신하는 로직을 처리하는 전역 axios를 사용하기 위해
import { api as axios } from "/src/boot/axios";
를 사용하였습니다.
이제 ReviewPage 컴포넌트를 불러올 때 어떤 로그가 찍히는지 확인해보겠읍니다/
global axios interceptors가 찍히는것을 알 수 있습니다!
끝
'Vue' 카테고리의 다른 글
[vue3 with pinia] 로그인 및 인증(1) (0) | 2024.06.13 |
---|---|
Vue 템플릿 재사용을 위한 "slot" (2) | 2024.06.07 |