[Next.js] 로그인 구현 (Silent refresh)

JunSeok·2023년 2월 15일
8

Movie-inner 프로젝트

목록 보기
11/13
post-thumbnail
post-custom-banner

구현 목표

회원가입 이후 로그인과 로그인 유지를 구현하고자 한다.
로그인 유지 방법으로 JWT을 사용했다.
자세한 백엔드 코드 내용은 [Node.js] Access Token과 Refresh Token으로 로그인 유지하기

구현 flow

백엔드에서 처리

  • 회원가입 또는 로그인 할 시, 서버에서 유저의 JWT 발급하고 Refresh token을 DB와 브라우저의 쿠키에 저장한다.

로그인에서 처리

  • 로그인되는 순간 해당 유저의 Refresh token을 기반으로 서버에서 발급한Access token을 받아온다.
  • 클라이언트에서는 발급된 Access token을 redux에 저장하여 관리하며 이 토큰을 이용하여 로그인이 필요한 작업을 수행한다.
  • 만료되기 1분전에 Access token을 재발급받는다. => Silent refresh
  • 정보 요청 시 header에 Access token을 같이 동봉해서 보내주면 서버에서 확인하여 인증 완료시 작업을 허락해준다.

헤더에서 처리

  • Redux에 저장되는 Access token은 reload되면 사라진다.
  • 브라우저 쿠키에 Refresh token만 있다면 언제든지 재발급 가능하다.
  • 헤더는 모든 페이지에서 공유된다.
    => 헤더 창에서 reload될 때마다 브라우저 쿠키에 Refresh token이 있다면 Access token을 재발급해준다. => silent refresh
  • Login UI 구현
    - 브라우저 쿠키에 refresh token이 있다면 자동으로 access token이 발급되는 점을 이용 => refresh token으로 로그인 유무 관리
    • refresh token이 있다면 UI를 로그아웃으로 설정
    • refresh token이 없다면 UI를 로그인으로 설정

구현

로그인 성공 시 실행 함수 onLoginSuccess

  • 로그인 성공 시 서버에서 발급한 액세스 토큰을 리턴값으로 받는다.
  • 리턴받은 액세스 토큰을 Redux에 저장한다.
  • api요청할 때마다 Access token을 헤더에 담아서 전송
  • Access token 만료시간 1분 전에 재발급해준다.
    (Access token 만료시간은 내가 설정한 값)

토큰 재발급 실행 함수 onSilentRefresh

Access token이 만료되기 1분 전에, 쿠키에 Refresh token이 있다면 자동으로 access token을 새롭게 발급해준다 => Silent refresh

  • 만료 1분 전에 Access token을 서버에 요청한다.
  • 새롭게 발급받은 Access token을 다시 onLoginSuccess 함수로 보낸다.

구현 코드

// Login.tsx

import { FormContainer, FormDiv, LoginContainerDiv, LoginDiv, LoginFailText, LoginSecondDiv, LoginSustainDiv, SubmitInput } from './Login.style'

import { CheckText } from '../Signup/Signup.style'
import Link from 'next/link'
import Router from 'next/router'
import SocialLogin from './SocialLogin'
import { apiInstance } from '../../../apis/setting'
import axios from 'axios'
import { setToken } from '../../../store/reducers/logintokenSlice'
import { toast } from 'react-toastify'
import { useDispatch } from 'react-redux'
import { useState } from 'react'

// access token 만료기한
const JWT_EXPIRY_TIME = 3600 * 1000
const Login = () => {
  	// api 요청시 사용하는 서버 주소
    axios.defaults.baseURL = 'http://localhost:3714'

    const [check, setCheck] = useState({
        user: true,
        login: true,
    })
    const [values, setValues] = useState({
        email: '',
        pw: '',
    })

    const dispatch = useDispatch()

    // 로그인 성공시 실행 함수, 토큰값을 받아 리덕스에 저장, 만료기한 1분 전 onSlientRefresh 함수 실행
    const onLoginSuccess = (response) => {
        const { accessToken } = response.data
        
        // api요청할 때마다 accessToken을 헤더에 담아서 전송
        axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}` 
        dispatch(setToken(accessToken))
        setTimeout(onSilentRefresh, JWT_EXPIRY_TIME - 60000) // 만료 1분 전에 재발급 함수
        console.info('login')
    }

    // accessToken 재발급 & onLoginSuccess 함수 실행
    const onSilentRefresh = async () => {
        try {
            const response = await apiInstance.post('/auth/refresh')
            onLoginSuccess(response)
            console.info('silent-success')
        } catch (e) {
            console.log(e.response)
        }
    }

    // 일반로그인 시 email, password 입력
    // true: 토큰 발급, false: 로그인 실패 알림(email,password) => 잘못된 이메일, 잘못된 비밀번호 확인
    const handleSubmit = async (e) => {
        e.preventDefault()
        try {
            // 이메일 유무 확인
            const response = await apiInstance.post('/users/login', { email: values.email, password: values.pw })
            // 로그인 성공 유무
            if (response.data.isEmailEqual) {
                if (response.data.isPasswordEqual) {
                    try {
                      	// 서버로부터 토큰 발급 받기
                        const tokenResponse = await apiInstance.post('/auth', { email: values.email })
                        // 발급받은 토큰으로 onLoginSuccess 실행
                        onLoginSuccess(tokenResponse)
                        Router.replace('/')
                        toast.success('로그인되었습니다!')
                    } catch (e) {
                        console.log(e.response)
                    }
                } else {
                    setCheck({
                        ...check,
                        login: false,
                        user: true,
                    }) // 로그인 실패, 이메일이나 비밀번호 확인
                    toast.error('비밀번호가 다릅니다!')
                }
            } else {
                toast.error('이메일이 존재하지 않습니다!')
                setCheck({
                    ...check,
                    login: false,
                    user: false,
                }) // 존재하지 않는 이메일
            }
        } catch (e) {
            console.log(e.response)
        }
    }
    const handleChange = (e) => {
        e.preventDefault()
        const { name, value } = e.target
        setValues({ ...values, [name]: value })
    }

    return (
        <LoginContainerDiv>
            <LoginDiv>
                <p>로그인</p>
            </LoginDiv>
            <FormDiv>
                <FormContainer onSubmit={handleSubmit}>
                    <div>Email</div>
                    <input type='email' name='email' onChange={handleChange} placeholder='이메일을 입력하세요' autoComplete='off' />
                    <div>Password</div>
                    <input type='password' name='pw' onChange={handleChange} placeholder='비밀번호를 입력하세요' autoComplete='off' />
                    <LoginSustainDiv>
                        <div>
                            <input type='checkbox' />
                            <div>로그인 유지하기</div>
                        </div>
                        <LoginFailText>
                            {!check.login && check.user && <CheckText check={false}>잘못된 비밀번호 입니다. </CheckText>}
                            {!check.user && !check.login && <CheckText check={false}>존재하지 않는 이메일입니다.</CheckText>}
                        </LoginFailText>
                    </LoginSustainDiv>
                    <SubmitInput type='submit' value='로그인' />
                </FormContainer>
            </FormDiv>
            <LoginSecondDiv>
                <Link href='/forgot'>
                    <button>비밀번호 찾기</button>
                </Link>
                <Link href='/signup'>
                    <button>회원가입</button>
                </Link>
            </LoginSecondDiv>
            <SocialLogin />
        </LoginContainerDiv>
    )
}

export default Login

헤더에서의 토큰 관리

헤더에서 Access token을 관리해줌으로써 로그인 UI를 제어한다.

  • 로그인 전
  • 로그인 후

구현 flow

헤더는 모든 페이지가 공유하는 부분이기 때문에 헤더에서 브라우저 쿠키에 Refresh token이 있는지 체크하고, 있다면 Access token 발급해준다.

  • 브라우저 쿠키에 Refresh token이 있다면 access token 재발급
  • 토큰이 바뀔 때마다 토큰 관련 redux 값도 같이 변경
  • 로그아웃 시 서버에서 refresh token 삭제하고 response가 true면 클라이언트에서 가지고 있던 redux가 관리하는 access token과 토큰 관련 사용자 정보 삭제
  • Refresh token이 없다면 로그인도 되지 않기 때문에 UI는 로그인과 회원가입으로 표시한다.

구현 코드

// HeaderUser.tsx

import { setEmail, setIdx, setNickname, setToken } from '../../../store/reducers/logintokenSlice'
import { useEffect, useState } from 'react'

import { CommonLogout } from '../../Common/CommonLogout'
import HeaderSettingModal from './HeaderSettingModal'
import { HeaderUserBox } from './HeaderUser.style'
import Image from 'next/image'
import { RiArrowDownSLine } from 'react-icons/ri'
import { apiInstance } from '../../../apis/setting'
import { toast } from 'react-toastify'
import { useDispatch } from 'react-redux'
import { useRouter } from 'next/router'

const HeaderUser = (props) => {
    const { loginToken, userImage } = props
    const [loginToggle, setLoginToggle] = useState(false)
    const [showSetting, setShowSetting] = useState(false)
    const dispatch = useDispatch()
    const router = useRouter()

    const clickSetting = () => setShowSetting(!showSetting)
    
    // 브라우저에 refreshToken이 있으면 액세스 토큰 재발급
    useEffect(() => {
        const refreshTokenCheck = async () => {
            const refreshTokenResponse = await apiInstance.get('/auth')
            const { isRefreshToken } = refreshTokenResponse.data
            if (isRefreshToken) {
                try {
                    const response = await apiInstance.post('/auth/refresh')
                    dispatch(setToken(response.data.accessToken))
                } catch (e) {
                    console.log(e.response)
                }
            } else {
                console.log('none-refreshToken')
            }
        }
        refreshTokenCheck()
    }, [])

    // 토큰 바뀔 때마다 필요한 redux 값 재설정
    useEffect(() => {
        const getResponse = async () => {
            if (loginToken) {
                setLoginToggle(true)
                const tokenPayload = await apiInstance.post('/auth/verify', { token: loginToken })
                dispatch(setNickname(tokenPayload.data.payload.nickname))
                dispatch(setEmail(tokenPayload.data.payload.email))
                dispatch(setIdx(tokenPayload.data.payload.idx))
            } else setLoginToggle(false)
        }
        getResponse()
    }, [loginToken])
  
	// 로그아웃 시 저장되어 있는 값들 초기화
    const clickLoginLogout = async () => {
        if (loginToggle) {
         	// CommonLogout 함수는 로그아웃을 서버에 요청하는 함수
            if (CommonLogout()) {
                dispatch(setToken(''))
                dispatch(setNickname(''))
                dispatch(setEmail(''))
                dispatch(setIdx(0))
                router.replace('/')
                toast.success('로그아웃되었습니다!')
            }
        }
        else router.push('/login')
    }

    return (
        <HeaderUserBox>
            {!loginToken
                ?
                <>
                    <button onClick={clickLoginLogout}>{loginToken ? '로그아웃' : '로그인'}</button><button onClick={() => router.push('/signup')}>회원가입</button>
                </>
                :
                <>
                    <div onClick={clickSetting}>
                        <Image src={userImage?.image_URL ? userImage?.image_URL : '/blank.png'} width={45} height={45} alt='프로필 이미지' />
                        <RiArrowDownSLine size={20} />
                    </div>
                    {showSetting ? <HeaderSettingModal clickSetting={clickSetting} /> : null}
                </>
            }
        </HeaderUserBox>
    )
}

export default HeaderUser
profile
최선을 다한다는 것은 할 수 있는 한 가장 핵심을 향한다는 것
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 9월 13일

안녕하세요! 잘 보고 있습니다. 덕분에 도움이 많이 되네요.
혹시 상태관리 툴을 redux로 사용하신 계기가 있으실까요?

답글 달기