회원가입 이후 로그인과 로그인 유지를 구현하고자 한다.
로그인 유지 방법으로 JWT을 사용했다.
자세한 백엔드 코드 내용은 [Node.js] Access Token과 Refresh Token으로 로그인 유지하기
Access token이 만료되기 1분 전에, 쿠키에 Refresh token이 있다면 자동으로 access token을 새롭게 발급해준다 => Silent refresh
// 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를 제어한다.
헤더는 모든 페이지가 공유하는 부분이기 때문에 헤더에서 브라우저 쿠키에 Refresh token이 있는지 체크하고, 있다면 Access token 발급해준다.
// 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
안녕하세요! 잘 보고 있습니다. 덕분에 도움이 많이 되네요.
혹시 상태관리 툴을 redux로 사용하신 계기가 있으실까요?