Spring Security 백엔드, 프론트앤드 분리 (Spring Boot 3.x, 프론트 Vue3) 기본 인증 프로젝트 예제

mocaccino·2024년 11월 5일
0

개인 프로젝트

목록 보기
2/2

이전 글에서 이어서 작성하는 프론트 코드

들어가기전에

1편을 보지 않았다면 먼저 1편부터 읽고 오시기를 바랍니다.

🌳 프론트엔드

🎨 프론트앤드

  • Vue3
  • 사용기술 : vue router, pinia, vuetify, axios
  •  port   8085

프론트앤드 파일 구조

public
├─src
   ├─assets
   ├─components
   ├─plugins
   ├─routers
   │  └─routes
   └─stores

1. Package.json

  • 프로젝트에 필요한 dependency 참고
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve --port 8085",
    "build": "vue-cli-service build --watch",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "@mdi/font": "^7.4.47",
    "@stomp/stompjs": "^7.0.0",
    "axios": "^1.7.4",
    "core-js": "^3.8.3",
    "pinia": "^2.2.2",
    "pinia-plugin-persistedstate": "^4.0.0-alpha.4",
    "qs": "^6.13.0",
    "sockjs-client": "^1.6.1",
    "stompjs": "^2.3.3",
    "vue": "^3.2.13",
    "vue-router": "^4.0.13"
  },
  "devDependencies": {
    "@babel/core": "^7.12.16",
    "@babel/eslint-parser": "^7.12.16",
    "@vue/cli-plugin-babel": "~5.0.0",
    "@vue/cli-plugin-eslint": "~5.0.0",
    "@vue/cli-service": "~5.0.0",
    "eslint": "^7.32.0",
    "eslint-plugin-vue": "^8.0.3",
    "vite-plugin-vuetify": "^2.0.4",
    "vuetify": "^3.7.0"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/vue3-essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "@babel/eslint-parser"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead",
    "not ie 11"
  ]
}

2. vue.config.js

  • Vue 프로젝트의 프론트앤드 서버에서 프론트엔드와 백엔드 간의 프록시 설정을 통해 API 요청을 백엔드 서버로 전달할 수 있도록 설정한다.

🟢 outputDir : 빌드된 Vue 프로젝트의 결과물인 정적 파일이 저장되는 디렉토리를 지정한다.

  • 여기서는 빌드된 파일들이 백엔드 Spring Boot 프로젝트의 static 폴더로 저장되며, 백엔드 서버에서 Vue 프론트엔드를 제공할 수 있도록 구성했다.

🟢 devServer : 뷰의 개발서버 설정을 가지고 있다.

  • port로 Vue 프로젝트가 실행될 포트를 설정한다.

🟢 proxy : devServer가 백엔드 서버로 API 요청을 전달하도록 설정한다. 프록시 서버는 지정된 경로의 모든 요청을 자동으로 백엔드 서버로 포워딩한다.

  • "/" : 모든 경로에 대한 요청을 프록시 처리 함.
  • target : 프록시 요청이 전달될 백엔드 서버의 URL
  • changeOrigin : 프록시 요청의 원본을 백엔드 서버의 도메인으로 변경해서 CORS 이슈를 피함
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
  transpileDependencies: true,
  outputDir: "../src/main/resources/static",
  devServer: {
    port: 8085,
    proxy: {
      "/": {
        target: "http://localhost:8084",
        changeOrigin: true,
        logLevel: "debug",
        ws: false,
        pathRewirte: {
          "^/": "/",
        },
      },
    },
  },
});

3. App.vue

  • 전체적인 화면 구성은 상단의 NavigationBar와 router-view를 통한 각각의 컴포넌트로 이루어짐.
<template>
  <NavigationBar v-if="!$route.meta.hideNav" />
  <router-view />
</template>

<script>
import NavigationBar from "@/components/NavigationBar.vue";

export default {
  name: "App",
  components: { NavigationBar },
};
</script>

<style></style>

4. main.js

  • vue router, pinia, vuetify를 사용하기 위해 전역적으로 설정함.
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import vuetify from "./plugins/vuetify";
import routers from "./routers";
import axios from "axios";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";

const app = createApp(App);
const pinia = createPinia(App);

pinia.use(piniaPluginPersistedstate);

app.use(vuetify);
app.use(routers);
app.use(pinia);
app.provide("$axios", axios);
app.mount("#app");

5. index.js

 경로   src/routers

  • index.js 파일은 Vue Router 설정 파일로, Vue 애플리케이션에서 라우터를 정의하고 경로 이동에 대한 전처리 로직을 설정한다. 특히 사용자 인증 상태에 따라 특정 경로로 접근을 제한하고, 조건에 맞게 리다이렉트하는 역할을 한다.
  • routes 배열에는 각 경로와 그에 대한 컴포넌트를 매핑한다.
    기본 경로(/)는 HomeComponent로 리다이렉트된다.
  • autenticateIndex: 인증 관련 경로들을 관리하는 모듈을 임포트하여, 인증 관련 컴포넌트들을 라우터에 추가한다.
  • beforeEach 훅을 통해 경로 이동 시 인증 상태를 확인하여 특정 조건에 따라 리다이렉트를 처리한다.

    🟢 조건별 경로 처리 로직

    - 회원가입 페이지로의 접근 허용 : 인증되지 않은 상태에서 SignupComponent로 이동하려면 그대로 접근을 허용한다.

    - 로그인 페이지가 아닌 경로 접근 시 리다이렉트 : 인증되지 않은 상태에서 /login 이외의 경로에 접근할 경우 로그인 페이지(LoginComponent)로 리다이렉트

    - 이미 인증된 상태에서 로그인 페이지 접근 제한 : 이미 인증된 상태에서 다시 로그인 페이지로 이동하려고 하면 메인 페이지(HomeComponent)로 리다이렉트한다.

    - 그 외의 경우 : 조건에 해당하지 않으면 원래 이동하려던 경로로 이동을 허용한다.

  • isAuthenticated : localStorage에서 사용자 인증 정보를 가져와 인증 여부를 확인한다. userData가 존재하면 사용자가 인증된 것으로 간주한다.
import { createWebHistory, createRouter } from "vue-router";

import autenticateIndex from "./routes/autenticateIndex";

const routes = [
  {
    path: "/",
    redirect: { name: "HomeComponent" },
  },
  ...autenticateIndex,
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

router.beforeEach((to, _, next) => {
  const isAuthenticated = !!localStorage.getItem("userData");

  if (!isAuthenticated && to.name === "SignupComponent") {
    return next();
  } else if (!isAuthenticated && to.name !== "LoginComponent") {
    // 인증되지 않았고, /login 이외의 경로에 접근할 경우 /login으로 리다이렉트
    return next({ name: "LoginComponent" });
  } else if (isAuthenticated && to.name === "LoginComponent") {
    // 이미 인증되었고, /login으로 이동하려고 하면 /main으로 리다이렉트
    return next({ name: "HomeComponent" });
  } else {
    return next();
  }
});

export default router;

6. autenticateIndex.js

 경로   src/routers/routes

import HomeComponent from "@/components/HomeComponent.vue";
import LoginComponent from "@/components/LoginComponent.vue";
import SignupComponent from "@/components/SignupComponent.vue";

export default [
  {
    path: "/main",
    name: "HomeComponent",
    component: HomeComponent,
    meta: { requiresAuth: true },
  },
  {
    path: "/login",
    name: "LoginComponent",
    component: LoginComponent,
    meta: { hideNav: true }, // 네비게이션 바 숨김 설정
  },
  {
    path: "/signup",
    name: "SignupComponent",
    component: SignupComponent,
    meta: { hideNav: true }, // 네비게이션 바 숨김 설정
  },
];

7. NavigationBar.vue

 경로   src/components

  • 화면 상단에 위치하는 네비게이션 바
  • 어떤 user가 로그인 했는지 표시해준다.
  • 로그아웃 버튼을 누르면 로그아웃 api를 요청하고 로컬스토리지에서 사용자 데이터를 삭제한다. /login 경로로 이동한다.
<template>
  <div id="navigation">
    <v-app>
      <v-app-bar :elevation="2">
        <template v-slot:prepend>
          <v-app-bar-nav-icon></v-app-bar-nav-icon>
        </template>
        <v-app-bar-title> Application </v-app-bar-title>
        <v-btn variant="text" prepend-icon="mdi-home" @click="home"></v-btn>
        {{ username + "님" }}
        <v-btn
          variant="text"
          prepend-icon="mdi-user"
          @click="openLogoutDialog()"
          >Logout</v-btn
        >
      </v-app-bar>
    </v-app>
  </div>
  <DialogComponent
    v-if="showLogoutDialog"
    openDialog="로그아웃하시겠습니까?"
    @confirm="logout"
    @cancel="showLogoutDialog = false"
  />
</template>

<script>
import { useUserStore } from "@/stores/userStore";
import DialogComponent from "./DialogComponent.vue";
import axios from "axios";

export default {
  name: "NavigationBar",
  data() {
    return {
      username: "",
      showLogoutDialog: false,
      userData: JSON.parse(localStorage.getItem("userData")),
    };
  },
  components: {
    DialogComponent,
  },
  methods: {
    home() {
      this.$router.push("/main");
    },
    openLogoutDialog() {
      this.showLogoutDialog = true; // 다이얼로그 열기
    },
    logout() {
      axios
        .get("/logout/" + this.userData.username)
        .then((response) => {
          console.log(response);

          localStorage.removeItem("userData"); // 로컬 스토리지에서 사용자 데이터 삭제
          this.username = ""; // username 초기화
          this.showLogoutDialog = false; // 다이얼로그 닫기
          this.$router.push("/login");
        })
        .catch((error) => {
          console.error("There was an error!", error);
        });
    },
  },
  mounted() {
    const userStore = useUserStore();

    if (userStore.username) {
      this.username = userStore.username; // 사용자 이름 설정
    } else {
      this.username = "";
    }
  },
};
</script>

<style scoped>
#navigation {
  height: 80px;
}
</style>

8. LoginComponent

 경로   src/components

  • http://localhost:8085 로 접속 시 나타나는 로그인 컴포넌트

    이미 로그인 해서 로컬 스토리지에 userData가 있다면 위 경로 접속 시 /main 페이지로 리다이렉트되고, 로그인 하지 않았다면 /login 페이지로 리다이렉트된다.

<template>
  <div>
    <v-card
      class="mx-auto pa-12 pb-8"
      elevation="8"
      max-width="448"
      rounded="lg"
    >
      <div class="text-subtitle-1 text-medium-emphasis">Account</div>
      <v-text-field
        density="compact"
        placeholder="Email address"
        prepend-inner-icon="mdi-email-outline"
        variant="outlined"
        v-model="username"
      ></v-text-field>
      <div
        class="text-subtitle-1 text-medium-emphasis d-flex align-center justify-space-between"
      >
        Password
        <a
          class="text-caption text-decoration-none text-blue"
          href="#"
          rel="noopener noreferrer"
          target="_blank"
        >
          Forgot login password?</a
        >
      </div>

      <v-text-field
        :append-inner-icon="visible ? 'mdi-eye-off' : 'mdi-eye'"
        :type="visible ? 'text' : 'password'"
        density="compact"
        placeholder="Enter your password"
        prepend-inner-icon="mdi-lock-outline"
        variant="outlined"
        @click:append-inner="visible = !visible"
        v-model="password"
      ></v-text-field>
      <b-card-text class="mb-2" style="color: red">
        {{ errorMsg }}
      </b-card-text>
      <v-card class="mb-12" color="surface-variant" variant="tonal">
        <v-card-text class="text-medium-emphasis text-caption">
          Warning: After 3 consecutive failed login attempts, you account will
          be temporarily locked for three hours. If you must login now, you can
          also click "Forgot login password?" below to reset the login password.
        </v-card-text>
      </v-card>

      <v-btn
        class="mb-8"
        color="blue"
        size="large"
        variant="tonal"
        @click="login()"
      >
        Log In
      </v-btn>

      <v-card-text class="text-center">
        <a
          class="text-blue text-decoration-none"
          href="#"
          rel="noopener noreferrer"
          target="_blank"
          @click.prevent="signup()"
        >
          Sign up now <v-icon icon="mdi-chevron-right"></v-icon>
        </a>
      </v-card-text>
    </v-card>
  </div>
</template>

<script>
import axios from "axios";
import qs from "qs";
import { useUserStore } from "@/stores/userStore";

export default {
  name: "LoginComponent",
  data() {
    return {
      username: "",
      password: "",
      visible: false,
      errorMsg: null,
      rules: {
        required: (value) => !!value || "Required.",
        counter: (value) => value.length <= 20 || "Max 20 characters",
        email: (value) => {
          const pattern =
            /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
          return pattern.test(value) || "Invalid e-mail";
        },
      },
    };
  },
  methods: {
    login() {
      this.errorMsg = null;
      const data = {
        username: this.username,
        password: this.password,
      };
      axios
        .post("http://localhost:8084/login", qs.stringify(data), {
          headers: {
            "content-type": "application/x-www-form-urlencoded",
          },
        })
        .then((response) => {
          if (response.data.message === "login") {
            const userStore = useUserStore();
            userStore.setUsername(this.username);
            this.$router.push("/main");
          } else {
            this.errorMsg = "아이디 혹은 패스워드가 올바르지 않습니다.";
          }
        })
        .catch((error) => {
          // 로그인 실패 시 오류 메시지 설정
          this.errorMsg = "아이디 혹은 패스워드가 올바르지 않습니다.";
          console.error("로그인 실패:", error);
        });
    },
    signup() {
      console.log("32");
      this.$router.replace("/signup");
    },
  },
};
</script>

<style scoped>
.v-card {
  margin-top: 50px;
}
</style>

9. HomeComponent

 경로   src/components

  • 로그인 성공 시 이동하는 main page
<template>
  <div id="app">
    <h2>Main Component</h2>
    Main Page
  </div>
</template>

<script>
export default {
  name: "HomeComponent",
};
</script>

<style scoped>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

10. DialogComponent

 경로   src/components

  • 로그아웃 버튼 클릭 시 표시되는 Dialog
<template>
  <v-dialog v-model="dialog" width="auto">
    <v-card width="500" prepend-icon="mdi-update" title="알림">
      <v-card-text>{{ openDialog }}</v-card-text>
      <v-card-actions>
        <v-btn class="ms-auto" text="확인" @click="confirmLogout"></v-btn>
        <v-btn class="ms-auto" text="취소" @click="cancelLogout"></v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

<script>
export default {
  props: {
    openDialog: String,
  },
  data() {
    return {
      dialog: true,
    };
  },
  methods: {
    confirmLogout() {
      this.$emit("confirm"); // 상위 컴포넌트에 confirm 이벤트 전달
      this.dialog = false;
    },
    cancelLogout() {
      this.$emit("cancel"); // 상위 컴포넌트에 cancel 이벤트 전달
      this.dialog = false;
    },
  },
};
</script>

11. SignupComponent

 경로   src/components

  • 로그인 페이지에서 하단의 Sign up now를 클릭하면 나타나는 회원가입 컴포넌트
<template>
  <div>
    <v-card
      class="mx-auto pa-12 pb-8"
      elevation="8"
      max-width="448"
      rounded="lg"
    >
      <div class="text-subtitle-1 text-medium-emphasis">Create Account</div>

      <v-text-field
        density="compact"
        placeholder="Email address"
        prepend-inner-icon="mdi-email-outline"
        variant="outlined"
        v-model="email"
        :rules="[rules.required, rules.email]"
      ></v-text-field>

      <div class="text-subtitle-1 text-medium-emphasis">Password</div>
      <v-text-field
        :append-inner-icon="passwordVisible ? 'mdi-eye-off' : 'mdi-eye'"
        :type="passwordVisible ? 'text' : 'password'"
        density="compact"
        placeholder="Enter your password"
        prepend-inner-icon="mdi-lock-outline"
        variant="outlined"
        @click:append-inner="passwordVisible = !passwordVisible"
        v-model="password"
        :rules="[rules.required, rules.counter]"
      ></v-text-field>

      <div class="text-subtitle-1 text-medium-emphasis">Confirm Password</div>
      <v-text-field
        :append-inner-icon="confirmPasswordVisible ? 'mdi-eye-off' : 'mdi-eye'"
        :type="confirmPasswordVisible ? 'text' : 'password'"
        density="compact"
        placeholder="Confirm your password"
        prepend-inner-icon="mdi-lock-outline"
        variant="outlined"
        @click:append-inner="confirmPasswordVisible = !confirmPasswordVisible"
        v-model="confirmPassword"
        :rules="[rules.required, rules.counter, rules.matchPassword]"
      ></v-text-field>

      <b-card-text class="mb-2" style="color: red">
        {{ errorMsg }}
      </b-card-text>

      <v-btn
        class="mb-8"
        color="blue"
        size="large"
        variant="tonal"
        @click="signup()"
      >
        Sign Up
      </v-btn>

      <v-card-text class="text-center">
        <a class="text-blue text-decoration-none" href="#" @click="goToLogin()">
          Already have an account? Log in
        </a>
      </v-card-text>
    </v-card>
  </div>
</template>

<script>
import axios from "axios";

export default {
  name: "SignupComponent",
  data() {
    return {
      email: "",
      password: "",
      confirmPassword: "",
      passwordVisible: false,
      confirmPasswordVisible: false,
      errorMsg: null,
      rules: {
        required: (value) => !!value || "Required.",
        counter: (value) => value.length <= 20 || "Max 20 characters",
        email: (value) => {
          const pattern =
            /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
          return pattern.test(value) || "Invalid e-mail";
        },
        matchPassword: (value) =>
          value === this.password || "Passwords do not match",
      },
    };
  },
  methods: {
    signup() {
      console.log("this.email :: " + this.email);
      console.log("this.password :: " + this.password);

      axios
        .post("/user", {
          email: this.email,
          password: this.password,
        })
        .then(() => {
          this.$router.replace("/login"); // 회원가입 성공 시 로그인 페이지로 리다이렉트
        })
        .catch((error) => {
          console.error("Error during signup:", error);
          this.errorMsg =
            "회원가입 중 오류가 발생했습니다. 다시 시도해 주세요.";
        });
    },
  },
};
</script>

<style scoped>
.v-card {
  margin-top: 50px;
}
</style>

12. userStore.js

  • Pinia를 사용하여 로그인된 사용자의 데이터를 관리하는 스토어를 정의하는 파일
  • Vue 애플리케이션의 컴포넌트들 간에 사용자 정보를 손쉽게 공유할 수 있도록 설정한다. persist 옵션을 활성화하여 로그인 정보를 localStorage에 저장하고 브라우저를 새로 고침해도 상태가 유지되도록 설정합니다.

🧩 defineStore를 사용하여 userStore라는 스토어를 정의한다. 이 스토어는 Pinia의 상태 관리 기능을 사용하며, Vue 3에서 Vuex를 대신할 수 있는 심플한 상태 관리 스토어이다.
🧩 id 속성에 "userData"를 지정하여 이 스토어를 구분하고, localStorage에 저장될 키 이름으로도 사용한다.
🧩 setUsername(name): 이 메서드는 username 상태를 주어진 이름으로 설정합니다. 사용자가 로그인할 때 호출되어, 로그인된 사용자의 이름이 username에 저장된다.
🧩 enabled: true로 설정하여 상태 저장을 활성화한다.
🧩 strategies: localStorage를 사용해 데이터를 저장하며, 저장될 키 이름(key)을 "userData"로 지정한다.

import { defineStore } from "pinia";

export const useUserStore = defineStore({
  id: "userData", //  localStorage에 저장될 키 이름
  state: () => ({
    username: "",
  }),
  actions: {
    setUsername(name) {
      this.username = name;
    },
  },
  persist: {
    enabled: true, // 이 옵션을 활성화해야 `localStorage`에 저장
    strategies: [
      {
        key: "userData", // localStorage에 저장될 키 이름
        storage: localStorage,
      },
    ],
  },
});
profile
레거시문서를 줄이자. 계속 업데이트해서 최신화한다.

0개의 댓글