토이 프로젝트 - Vue, Firebase로 서버리스 PWA 개발

9. Vue(with Vuetify)와 Firebase로 서버리스 PWA myBlog 개발 - 로그인 구현

그랜파 개발자 2025. 2. 9. 06:08

로그인에 성공을 하면 user 객체를 돌려 받고 이것을 state의 user 변수에 저장합니다.
이 user 변수가 null인지 확인을 하면 로그인 상태인지 로그아웃 상태인지 확인할 수 있습니다.

 

로그인 상태에 따라 Drawer의 메뉴 항목을 다르게 할 수 있습니다.
또 AppBar의 로그인, 로그아웃 아이콘 버튼을 로그인 상태에 따라 나타낼 수 있습니다.

로그인 창에 비밀번호 리셋 기능을 둡니다.
비밀번호 리셋은 비밀번호를 잊었을 때 사용합니다.

로그인

로그인은 Firebase Authentication에서 제공하는 메서드 signInWithEmailAndPassword를 사용하며,
로그인을 성공하면 UserCredential 객체를 받아 이것을 state의 user 변수에 저장합니다.

 
  // --로그인
  async login({ commit, dispatch }, { email, password }) {
    try {
      const userCredential = await signInWithEmailAndPassword(auth, email, password);
      commit("setUser", userCredential.user);
      dispatch("fetchProfile", userCredential.user); // 로그인한 회원의 계정 설정 정보
    } catch (error) {
      alert(error.message); // 에러 메시지 표시
    }
  },

로그아웃

로그아웃은 Firebase Authentication에서 제공하는 메서드 signOut을 사용하며,
현재 로그인한 사용자를 로그아웃시키는 기능을 합니다.
이 메서드를 호출하면 Firebase 인증 세션이 종료됩니다.

 
  // 로그아웃
  async logout({ commit }) {
    try {
      await signOut(auth);
      commit("setUser", null);
      alert("로그아웃 했습니다!");
    } catch (error) {
      alert("Error logging out:" + error.message);
    }
  },

자동 로그인

자동 로그인은 Firebase Authentication에서 제공하는 메서드 onAuthStateChanged를 사용하며,
사용자의 로그인 상태 변화를 감지하는 기능을 합니다.
즉, 로그인 / 로그아웃 / 인증 상태 변화가 발생하면 자동으로 콜백 함수가 실행됩니다.

 
  // -- 앱을 시작하면 자동 로그인을 설정한다.
  async initializeAuth({ commit, dispatch }) {
    onAuthStateChanged(auth, (user) => {
      if (user) {        
        commit("setUser", user);
      } else {
        commit("setUser", null);
      }
    });
  },

main.js

// src/main.js
import Vue from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import store from './store'
import vuetify from './plugins/vuetify'

Vue.config.productionTip = false

new Vue({
  router,
  store,
  vuetify,
  render: h => h(App),
  created() {
    // Set up Firebase auth state change listener
    const { dispatch } = this.$store;
    // Initialize Firebase authentication to check for the logged-in user
    dispatch('auth/initializeAuth');
  }
}).$mount('#app')

비밀번호 재설정

비밀번호 재설정은 Firebase Authentication에서 제공하는 메서드 sendPasswordResetEmail을 사용하며,
사용자가 비밀번호를 잊었을 때 비밀번호 재설정 이메일을 전송하는 기능을 제공합니다.

 
  // 비밀번호 재설정
  async PasswordReset({}, email) {
    sendPasswordResetEmail(auth, email)
    .then(() => {
      alert("비밀번호 재설정 이메일이 발송되었습니다.");
    })
    .catch((error) => {
      alert("비밀번호 재설정 이메일 발송 중 오류 발생:", error);
    });
  },

로그인, 로그아웃 아이콘 - App.vue template

Drawer 메뉴 항목 - App.vue script

App.vue

<!-- src/App.vue -->
<template>
  <v-app>
    <!-- App Bar -->
    <v-app-bar color="primary" dark app>
      <v-app-bar-nav-icon @click="toggleDrawer" />
      <v-toolbar-title>마이 블로그</v-toolbar-title>
      <v-spacer></v-spacer>
      <div @click="doLogout" v-if="user" style="cursor: pointer">
        <v-icon left class="ml-1">mdi-logout</v-icon>
      </div>
      <div @click="doLogin" v-if="!user" style="cursor: pointer">
        <v-icon left class="ml-1">mdi-login</v-icon>
      </div>
    </v-app-bar>

    <!-- Navigation Drawer -->
    <v-navigation-drawer v-model="drawer" app :clipped="$vuetify.breakpoint.lgAndUp">
      <v-list>
        <v-list-item-group>
          <v-list-item v-for="(item, index) in GetMenuItems"
            :key="index"
            @click="$router.push(item.path).catch(() => { drawer = !drawer });">
            <v-list-item-icon>
              <v-icon>{{ item.icon }}</v-icon>
            </v-list-item-icon>
            <v-list-item-content>
              <v-list-item-title>{{ item.title }}</v-list-item-title>
            </v-list-item-content>
          </v-list-item>
        </v-list-item-group>
      </v-list>
    </v-navigation-drawer>

    <!-- Main Content -->
    <v-main>
      <v-container>
        <router-view /> <!-- 라우터가 여기에 컴포넌트를 렌더링 -->
      </v-container>
    </v-main>

    <!-- Footer -->
    <v-footer app color="secondary" dark>
      <v-btn icon v-if="$route.name !== 'Home'" @click="$router.go(-1)">
        <v-icon>mdi-arrow-left</v-icon>         
      </v-btn>

      <v-spacer></v-spacer>
      <router-link to="/" style="cursor: pointer">
        <v-icon>mdi-home</v-icon>
      </router-link>
    </v-footer>

  </v-app>
</template>

<script>
import { mapState, mapActions } from 'vuex';
import router from '@/router';

export default {
  data() {
    return {
      drawer: false,
      menuItems: [

      ],
    };
  },
  computed: {
    ...mapState('auth', ['user']),
    GetMenuItems() {
      if(this.user) {
        return [
          { title: '홈', icon: 'mdi-home', path: '/' },
          { title: '글쓰기', icon: 'mdi-pencil', path: '/write' },
          { title: '블로그', icon: 'mdi-post', path: '/blog/myblog' },
          { title: '구독', icon: 'mdi-account-heart', path: '/subscription' },
          { title: '독자', icon: 'mdi-account-details', path: '/readers' },  
          { title: '검색', icon: 'mdi-file-search-outline', path: '/search' },  
          { title: '알림 요청', icon: 'mdi-bell', path: '/notification' },  
          { title: '계정 설정', icon: 'mdi-account-box-edit-outline', path: '/profile' },
          { title: '카테고리 관리', icon: 'mdi-cog', path: '/category' },          
          { title: '마이블로그는?', icon: 'mdi-information', path: '/about' },
        ]
      } else {
        return [
          { title: '홈', icon: 'mdi-home', path: '/' },
          { title: '로그인', icon: 'mdi-login', path: '/login' },            
          { title: '검색', icon: 'mdi-file-search-outline', path: '/search' }, 
          { title: '계정 만들기', icon: 'mdi-account-box-edit-outline', path: '/register' },
          { title: '마이블로그는?', icon: 'mdi-information', path: '/about' },
        ] 
      }
    }
  },
  methods: {
    ...mapActions('auth', ['logout']),
    toggleDrawer() {
      this.drawer = !this.drawer;
    },
    doLogin() {
      if(this.$route.name !== 'login')
        router.push("/login");   // home으로
    },

    doLogout() {
      this.logout();

      if(this.$route.name !== 'Home')
        router.push("/");   // home으로
    },
  },
};
</script>

store의 auth 모듈

// src/store/modules/auth.js
import router from '@/router';
import { auth, db, collection, doc, getDoc, getDocs, addDoc, updateDoc,
  query, where, arrayUnion } from "@/firebase";
import { createUserWithEmailAndPassword, signInWithEmailAndPassword, 
  signOut, sendPasswordResetEmail, onAuthStateChanged, 
  GoogleAuthProvider, signInWithPopup } from "firebase/auth";

const state = {
  loading: false,
  user: null,     // 현재 로그인한 회원 
  profile: null,  // 계정설정 정보
  profiles: [],   // 모든 회원의 계정 설정 정보
};

const mutations = {
  setLoading(state, loading) {
    state.loading = loading;
  },
  setUser(state, user) {
    state.user = user;
  },
  setProfile(state, profile) {
    state.profile = profile;
  },
  setProfiles(state, profiles) {
    state.profiles = profiles;
  },
};

const actions = {
  // -- 계정 만들기
  async register({ commit, dispatch }, { email, password }) {
    try {
      const userCredential = await createUserWithEmailAndPassword(auth, email, password);
      commit('setUser', userCredential.user);

      // 계정 설정 정보 저장 - 공란으로 저장하고, 계정 설정에서 정보를 입력한다.
      const profile = {
        userId: userCredential.user.uid,
        email:email,
        name: '',   
        blogName: '',   
        aboutMe: '',    
        createdAt: new Date(),  
        uids: [userCredential.user.uid]
      }
      dispatch('registerProfile', profile);

    } catch (error) {
      alert("register : " + error);
    }
  }, 

  // 계정 설정 정보를 저장한다.
  async registerProfile({ dispatch }, profile) {
    try {
      const profileRef = await addDoc(collection(db, "profiles"), { 
        userId: profile.userId,
        email: profile.email,
        name: profile.name,
        blogName: profile.blogName,
        aboutMe: profile.aboutMe,
        createdAt: profile.createdAt,
        uids: profile.uids
      }); 
      // profile 문서 id를 추가한다.
      //profile.id = profileRef.id;
      //dispatch('fetchProfile', profile.id);

      // 전체 계정 설정 정보를 다시 로드한다.
      // 개별 profile 로드도 포함되어 있다.
      dispatch('fetchProfiles');
    } catch (error) {
      alert("Failed to register user. : " + error);
    }
  },

  // 기존의 계정 설정 정보를 수정 한다.
  async updateProfile({ dispatch, commit, state }, profile) {
    //console.log(state.profile);
    try {
      commit('setLoading', true);

      const userDoc = doc(db, "profiles", state.profile.id);
      await updateDoc(userDoc, profile);

      //dispatch('fetchProfile', profile.id);     
      // 전체 계정 설정 정보를 다시 로드한다.
      // 개별 profile 로드도 포함되어 있다.
      dispatch('fetchProfiles');

      commit('setLoading', false);

      alert("계정 정보를 수정하였습니다.");
    } catch (error) {
      alert("계정 정보 수정 실패: " + error.message);
    }
  },

  // -- 계정 설정 정보 로드
  async fetchProfile({ commit }, profileId) {
    try {
      const profileRef = doc(db, "profiles", profileId);
      const profileSnap = await getDoc(profileRef);   // 문서 가져오기
      if (profileSnap.exists()) {
        //console.log('profile: ', profileSnap.data());
        // 계정 설정 정보로 로그인한 사용자 저장
        commit("setProfile", {id:profileId, ...profileSnap.data()}); 
      } else {
        console.log("fetchProfile : " + userId + " 계정 설정 정보가 없습니다.");
      }
    } catch (error) {
      alert("Failed to fetch user. : " + error.message);
    }
  },

  // 전체 회원의 계정 설정 정보를 로드한다.
  async fetchProfiles({ commit, dispatch, state }) {  
    try {
      const profiles = [];

      const profileRef = collection(db, "profiles");
      const querySnapshot = await getDocs(profileRef);
      querySnapshot.forEach((doc) => { 
        profiles.push({ id: doc.id, ...doc.data() });
      });

      commit('setProfiles', profiles);

      // login 사용자가 있으면 pfofile 로드
      if(state.user) {
        const profile = state.profiles.find(profile => profile.uids && profile.uids.includes(state.user.uid));
        //console.log(profile);
        if(profile)
          dispatch("fetchProfile", profile.id);
      }
    } catch (error) {
      console.log('fetchProfiles : ' + error.message);
    } 
    //commit('setLoading', false);
  },

  // --로그인
  async login({ commit, dispatch }, { email, password }) {
    try {
      const userCredential = await signInWithEmailAndPassword(auth, email, password);
      commit("setUser", userCredential.user); 
      // 계정 uid로 profile 문서를 구한다.
      const profile = state.profiles.find(profile => profile.uids && profile.uids.includes(userCredential.user.uid));
      dispatch("fetchProfile", profile.id); 
    } catch (error) {
      alert("login: " + error.message); // 에러 메시지 표시
    }
  },

  // 로그아웃
  async logout({ commit }) {
    try {
      await signOut(auth);
      commit("setUser", null);
      alert("로그아웃 했습니다!");
    } catch (error) {
      alert("Error logging out:" + error.message);
    }
  },

  // -- 앱을 시작하면 자동 로그인을 설정한다.
  // onAuthStateChanged는 각 OAuth의 user 객체를 사용
  async initializeAuth({ commit }) {
    onAuthStateChanged(auth, (user) => {
      if (user) {        
        commit("setUser", user);      
      } else {
        commit("setUser", null);
      }
    });
  },

  // 비밀번호를 변경한다.
  async changePassword({}, { oldPassword, newPassword }) {  

    const credential = EmailAuthProvider.credential(
      auth.currentUser.email,
      oldPassword
    );

    reauthenticateWithCredential(auth.currentUser, credential)
    .then(() => {
      updatePassword(auth.currentUser, newPassword)
      .then(() => {
        alert("비밀번호가 성공적으로 변경되었습니다.");
      }).catch((error) => {
        alert("비밀번호 변경 실패 : " + error.message);
      });
    })
    .catch((error) => {
      alert("재인증 실패 : " + error.message);
    });
  },

  // 비밀번호 재설정
  async PasswordReset({}, email) {
    sendPasswordResetEmail(auth, email)
    .then(() => {
      alert("비밀번호 재설정 이메일이 발송되었습니다.");
    })
    .catch((error) => {
      alert("비밀번호 재설정 이메일 발송 중 오류 발생:", error);
    });
  },

  // -- OAuth(구글 계정) 로그인 
  // 구글 계정의 uid를 profile의 uids 배열에 넣는다.
  async addUidToProfile({ state }, newUid) {
    try {
      const profileDoc = doc(db, "profiles", state.profile.id);
      updateDoc(profileDoc, {
        uids: arrayUnion(newUid)
      });
    } catch (error) {
      alert('Error adding UID to user:' + error);
    }    
  },

  async addGoogleAccount({ dispatch, state }) {
    try {
      const provider = new GoogleAuthProvider();
      // 이 메서드는 onAuthStateChanged를 호출한다.
      // 이로 인해 상태 변수 user가 변경된다. 
      const { user } = await signInWithPopup(auth, provider);
      try {
        // 이미 연동된 회원의 경우 알림 메시지 출력한다.
        //console.log(state.profile);
        const profile = state.profiles.find(profile => profile.uids && profile.uids.includes(user.uid));
        //console.log(profile);
        if (profile) {
          // 이미 연동되어 있다.
          alert('이미 구글 계정에 연동되어 있습니다.');
        } else {
          // profile의 uids에 구글 계정 uid를 추가한다.
          dispatch('addUidToProfile', user.uid);
        }          
      } catch (error) {
        alert('addGoogleAccount: ' + 'Error adding google uid');
      }
    } catch (error) {
      alert('addGoogleAccount: ' + error.message);
    }
  },
  // 구글 계정으로 로그인
  async googleLogin({ commit, dispatch, state }) {
    try {
      // 구글 계정에 로그인
      const provider = new GoogleAuthProvider();
      const { user } = await signInWithPopup(auth, provider)
      if(user) {
        // 구글계정의 uid로 profile을 가져온다.
        // 웹앱 계정은 사이트에 접속할 때 전체 회원 정보를 로드하였으므로 
        // 이미 로드된 회원 리스트에서 구글 계정 uid를 가진 profile을 가져온다.
        const profile = state.profiles.find(profile => profile.uids && profile.uids.includes(user.uid));
        if (profile) {
          // 이 경우 Firebase Auth가 아닌 Google Account임을 고려해야 한다.
          commit('setUser', user);  
          dispatch('fetchProfile', profile.id);

          router.push("/");   // home으로
        } else {
          alert('addGoogleAccount: ' + '등록된 회원이 아닙니다.');
        }
      }
    } catch (error) {
      alert('googleLogin: ' + error.message);
    }
  },

};

const getters = { 

};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
};

LoginView.vue

<!-- src/views/LoginView.vue -->
<template>
  <v-card>
    <v-card-title>로그인</v-card-title>
    <v-card-text>
      <v-form ref="form" v-model="valid" lazy-validation>
        <v-text-field label="이메일" v-model="email" :rules="[rules.required, rules.email]"
          prepend-icon="mdi-email" type="email" required></v-text-field>
        <v-text-field label="비밀번호" v-model="password" :rules="[rules.required]"
          prepend-icon="mdi-lock" type="password" required></v-text-field>
      </v-form>            
    </v-card-text>

    <v-card-actions>
      <v-btn @click="doPasswordReset"> 비밀번호 재설정 </v-btn>
      <v-spacer></v-spacer>
      <v-btn color="primary" @click="doLogin" :disabled="!valid"> 로그인 </v-btn>
    </v-card-actions>

  </v-card>
</template>

<script>
import { mapActions } from 'vuex';
import router from '@/router';

export default {
  data() {
    return {
      email: '',
      password: '',
      valid: false,
      rules: {
        required: (value) => !!value || "Required.",
        email: (value) =>
          /.+@.+\..+/.test(value) || "E-mail must be valid.",
      },
    };
  },
  methods: {
    ...mapActions('auth', ['login', 'PasswordReset']),
    async doLogin() {
      if (this.$refs.form.validate()) {
        await this.login({ email: this.email, password: this.password });
        router.push("/");   // home으로
      }
    },
    async doPasswordReset() {
      if (!this.email) {
        alert("비밀번호 재설정을 위한 이메일을 입력하세요.");
        return;
      }
      await this.PasswordReset(this.email);
    },    
  },
};
</script>