22일차 - 로그인, JWT, 쿠키/세션

류연찬·2023년 4월 29일
0

Codecamp FE07

목록 보기
22/39

로그인 방식은 대부분 필수적으로 포함되는 기능이죠. 과거에 브라우저의 쿠키/세션을 통한 로그인 정보 저장부터 현재 JWT 로그인을 활용 중에 있습니다. 한번 로그인 역사에 대해 알아보겠습니다.

쿠키/세션 로그인

첫번째 로그인의 역사

브라우저에서 특정 email과 password를 가지고 로그인을 하게되면 백엔드로 loginAPI 요청이 날라가게 되고, 백엔드에서는 해당 유저가 있는지 DB에 확인 후 있으면 session에 저장해두게 됩니다.

그 후 특정한 id를 부여해서 브라우저로 보내줍니다.

이렇게 보내진 id는 해당 유저가 뭔가를 요청할 때 본인이 누군지 식별 할 수 있도록 id를 함께 넣어서 보내줍니다.

이렇게 유저의 정보(Id)를 백엔드 서버로 받다보니 한번에 여러명의 정보를 받기엔 한계가 있었습니다.

이를 보완하기 위해서 백엔드 컴퓨터를 scale-up을 해주었습니다.

💡 scale-up
컴퓨터의 성능(cpu, memory 등) 및 사양을 올려주는 것 입니다.


두번째 로그인의 역사

이렇게 백엔드 컴퓨터의 성능을 올려주었음에도 불구하고 더 많은 유저의 접속이 동시 다발적으로 일어나면, 여전히 서버의 부하를 초래했습니다.

그래서 나온 방법으로 백엔드 컴퓨터를 복사하는 방법 이었습니다.

이 방법은 유저의 정보가 담기는 백엔드 컴퓨터를 복사해 여러대의 컴퓨터로 서버의 부하를 분산해주었습니다.

여기서의 문제점은 컴퓨터를 복사할때는 세션까지 scale out이 안되기때문에 기존의 로그인 정보를 가지고 있던 백엔드 컴퓨터가 아니면, 로그인 정보가 없습니다.

💡 scale-out
똑같은 성능의 컴퓨터를 추가하는 것 입니다.


세번째 로그인의 역사

이 방법은 위의 session을 scale-out해오지 못하는 문제점을 보완한 방법이며, 현재 많이 쓰이고 있는 방법입니다.

session을 복사해오지 못하자 사람들은 로그인 정보를 DB에 저장하기 시작했습니다.

하지만 결국 이것도 백엔드 서버의 부하가 DB로 옮겨진 것 이기 때문에 DB의 부하를 초래합니다.

보완을 위해 “DB를 복사하면 안되나?” 라고 생각하셨을 수 있지만, DB를 복사하는 방법은 비용문제가 발생하기 때문에 비효율적입니다.

따라서 위의 문제점은 데이터를 쪼개면서 해결하게 됩니다.

💡 DB를 쪼개는데는 2가지의 방법이 있습니다

  • 수직으로 쪼개는 수직파티셔닝
  • 수평으로 쪼개는 수평파티셔닝(샤딩)

그러나 여기에도 문제점은 있습니다.

DB는 컴퓨터를 껏다 켜도 날아가지 않기 때문에 데이터들이 disk에 저장됩니다.

따라서 안전하지만 느립니다. 우리는 결국 이렇게 disk에 저장된 데이터를 추출해 오는 현상을 DB를 긁는다고(scrapping) 표현합니다.

이를 해결하기위한 방법으로 Redis라는 데이터베이스에 저장해둔다. redis는 메모리에 저장하기 때문에 위의 문제점을 해결해준다.

💡 Redis
메모리에 저장해두는 임시 데이터 베이스

이렇게 저장된 특정 ID(토큰)을 다시 브라우저로 돌려주게 된다.

돌려받은 토큰은 브라우저 저장공간에 토큰을 저장해두고 어떤 행동을 할때 토큰을 같이 보내주어 사용자가 누구인지 식별한다.


JWT 로그인

네번째 로그인의 역사

똑똑한 사람들은 “로그인 정보를 굳이 서버나 DB에 저장해야 할까?” 생각을 했습니다.

그렇게 탄생한 것이 JWT 토큰입니다.

JWT 토큰은 유저 정보를 담은 객체를 문자열로 만들어 암호화한 후 암호화된 키(accessToken)를 브라우저에 줍니다.

받아온 암호화된 키는 브라우저 저장소에 저장해두었다가 유저의 정보가 필요한 API를 사용할 때 보내주게 되면,해당 키를 백엔드에서 복호화해서 사용자를 식별한 후 접근이 가능하도록 합니다.

JWT 토큰에는 해당 토큰이 발급 받아온 서버에서 정상적으로 발급을 받았다는 증명을하는 signature를 가지고 있습니다.

따라서 사용자의 정보를 DB를 열어보지 않고도 식별할 수 있게 되었습니다.


JWT 토큰과 조작 불가능성 이해

로그인 프로세스에 대해 알아보았으니 실제 로그인을 해보도록 하겠습니다.

로그인을 하기에 앞서 회원가입을 진행해주셔야 하며 회원가입을 해보신 분들은 바로 로그인으로 넘어가셔도 무방합니다.

⭐️ 회원가입과 로그인 모두 playGround에 들어가 Docs를 보고 진행해주셔야 합니다


회원가입 진행

로그인 진행

Docs를 보시면 우리가 받아와야 하는것에 accessToken이 있습니다.

그리고 받아온 accessToken 안에는 유저가 로그인 한 기록이 저장되어있습니다.

따라서 우리는 유저정보를 확인해야 하는 API를 사용할 때, accessToken을 첨부해 보내주시면 백엔드에서 유저정보를 확인후 해당 API를 사용할 수 있도록 해줍니다.


JWT의 이상한 점

그런데, JWT에는 한가지 이상한점이 있습니다.

우선 다음 사이트를 브라우저에 띄워주세요.( jwt.io )

그럼 Encoded부분과 decoded 부분을 볼 수 있습니다.

여기서 Encoded 부분에 우리가 받아온 accessToken을 넣어보도록 하겠습니다.

우리가 받아온 토큰을 넣어보니 decoded부분에 토큰에대한 정보가 모두 보이고 있습니다.

이처럼 누군가 우리의 토큰을 탈취해 해당 사이트에 넣어보면 토큰의 정보를 알 수 있습니다.

즉, JWT토큰은 암호화는 했지만 누구든 열어볼 수 있다는 것 입니다.

따라서 중요한 데이터는 JWT 토큰에 저장해서는 안됩니다.

Encoded => 암호화
Decoded => 복호화

💡 JWT 토큰의 구성

  • header : 토큰의 타입, 암호화 시 사용한 알고리즘 정보
  • payload : 토큰 발행 정보(누구인지, 언제 발행되었는지, 언제 만료될 것인지)
  • signature : 토큰의 비밀번호

누구든지 복호화가 가능하기 때문에 보안을 위해 토큰의 만료시간을 짧게 주었습니다.

그러나 복호화된 정보에는 토큰의 만료시간이 명시되어있었습니다. 즉 조작을 할수도 있다는 것 이지요.

하지만 이런 조작을 미연에 방지하기 위해 JWT는 signature(토큰의 비밀번호)를 사용합니다.

토큰의 내용을 조작하기 위해선 토큰의 비밀번호를 알아야 한다는 것 입니다.

해당 비밀번호는 백엔드에서 생성하며, 알 수 없습니다


단방향 암호화(해싱), 양방향 암호화

우리가 로그인을하고, 로그인 정보를 fetch해왔을 때 브라우저에 비밀번호를 fetch 할 수 없어야 합니다.

즉, 비밀번호를 알아내는게 불가능해야 합니다.

DB에 있는 비밀번호를 알아낼 수 있게되면, 해킹을 잘하는 사람이 DB를 해킹해왔을때 유저의 비밀번호를 알아낼 수 있게 되면 민감한 정보에 접근이 가능하게 됩니다.

또한 유저가 해당 사이트에서 사용하는 비밀번호를 다른 사이트에서도 사용하는 경우엔 문제가 더 심각해지게 됩니다.

따라서 비밀번호나 계좌번호같은 민감한 정보는 백엔드에 저장할때 그대로 저장하지 않습니다.

그럼 어떻게 저장하는지 알아보도록 하겠습니다.

어떻게 저장하는지 알아보려면 우리는 2가지 타입의 암호화를 알아야 할 필요가 있습니다.


단방향 암호화(해싱)

단방향 암호화는 암호화는 되지만 복호화는 안되는 것을 의미합니다.

ex) 275719 —암호화—> 779

앞에서 부터 2개씩 끊어가지고 10으로 나눈 나머지를 적어놓은 것이 779가 되겠습니다.

10으로 나눴을 때 나머지가 7이 되는 숫자는 27, 37, 47 등등 너무나도 많기 때문에 원래 정보가 뭔지 모르게 만드는 것 입니다.

이를 다대일 이라고 하는데, 이는 레인보우 테이블로 무작정 다 대입해서 복호화 하는 경우도 있습니다.

이부분을 보완하기위해서 조금더 어려운 알고리즘을 추가하기도 합니다.

따라서 민감한 정보를 저장할때는 해킹을 당해도 알아볼 수 없도록 단방향 암호화를 사용하여 저장하게 됩니다.

💡 authenticationauthorization

  • authentication(인증) : 로그인을 해서 토큰을 받아오는 과정
  • authorization(인가) : 리소스에 접근할 수 있도록 토큰을 확인하는 과정

양방향 암호화

양방향 암호화는 JWT같은 복호화가 되는 암호화를 말합니다.

즉, 암호화와 복호화 모두할 수 있는 암호화 입니다.


토큰을 같이 보내주는 방법

http header에 토큰을 넣어서 보내주시면 됩니다.

HTTP HEADERS에 “Authorization”을 보내주시면 됩니다.

위에서 사용되는 Bearer는 관례상 사용하는 것 일 뿐 반드시 사용하는 것 이 아닙니다.

이부분은 백엔드와 상의해서 사용하셔야 합니다. 만일 백엔드에서 Bearer가 아닌 Basic으로 받고있다면, Basic으로 보내주시면 됩니다.


로그인과 Recoil 연결

지금까지 로그인 기능을 구경했다면, 이제 직접 로그인 기능을 연결해보도록 하겠습니다.

기능을 구현해보기에 앞서 새로운 폴더를 생성해서 진행해주셔야 합니다.

우리가 구현할 기능은 로그인 후 로그인 성공 페이지까지 진행할 것 이므로, 로그인 폴더로그인 성공 폴더 두개를 만들어 주시면 됩니다.

// login 폴더의 index.tsx 화면 그려주기
import { ChangeEvent } from "react"
import { useMutation, gql } from "@apollo/client"

cosnt LOGIN_USER = gql`
	mutation loginUser($email: String){
		loginUser(email: $email, password: $password){
			accessToken
		}
	}
`

const LoginPage = () => {
	const [email, setEmail] = useState("")
	const [password, setPassword] = useState("")
	const [loginUser] = useMutation<Pick<IMutation, 'loginUser'>, IMutationLoginUserArgus>(LOGIN_USER)

	const onChangeEmail = (event: ChangeEvent<HTMLInputElement>) => {
		setEmail(event.target.value)
    }
    
	const onChangePassword = (event: ChangeEvent<HTMLInputElement>) => {
    	setPassword(event.target.value)
    }

	const onClickLogin = async() =>{
		try{
          cosnt result = await loginUser({
            variables: {
              email: email,
              password: password
            }
          })

          const accessToken = result.data?.loginUser.accessToken
		} catch(error) {
          // alert(error.message)을 사용하셔도 무방합니다
          Modal.error({content: error.message})
        }
     } 

	return(
		<div>
			이메일 : <input type="text" onchange={onChangeEmail}/> <br/>
			비밀번호 : <input type="password" onchange={onChangePassword}/> 
			<button onClick={onClickLogin}>로그인하기!!</button>
		</div>
	)
}
    
export default LoginPage

loginUser같은 경우엔 타입 추론이 불가능 합니다.

이럴때 우리는 앞에는 받아올 타입을 , 뒤에는 보내줄 타입을 직접 적어주었습니다.

입력하는 방법으로는 IMutation 에서 로그인 유저의 타입을 Pick 해주셔야 합니다.

💡 pick과 같이 쓰이는 타입 omit과 patial
pick, omit, patial은 모두 utility 타입입니다.
omit특정 데이터를 빼고 모두 데려와주는 역할을 하고, patial모두 가지고 오지만 모두 ?를 붙여서 가지고 오는 것 입니다.

💡 catch 부분에 에러가 떠요?!
타입스크립트의 버전에 따라 에러가 날 수도 있고, 안날 수도 있습니다.
에러가 뜨시는 분들은 아래의 코드를 적용해 보시길 바랍니다!

// ... code
catch(error) {
	if(error instanceof Error) {
      Modal.error({content: error.message})
    }
}

이제 playGround로 만들어둔 회원정보를 가지고 로그인을 해보겠습니다.

로그인을 하면 아래와 같이 accessToken을 잘 받아가지고 옵니다.

그런데 우리는 loginsuccess에서 로그인 한 유저의 정보를 가지고 오고있습니다.

따라서 우리는 유저의 정보를 가지고 오는 api를 사용할 것 인데, 사용을 위해 http header부분에 accessToken을 첨부해서 요청하셔야 합니다.

그렇다면 vscode에서 accessToken을 첨부하는 방법을 알아보도록 하겠습니다.


accessToken을 Recoil에 저장하기

// login 폴더의 index.tsx _ accessToken global state에 저장해주기
import { ChangeEvent } from "react"
import { useRouter } from "next/router"
import { useMutation, gql } from "@apollo/client"
import { useRecoilState } from "recoil";

cosnt LOGIN_USER = gql`
	mutation loginUser($email: String){
		loginUser(email: $email, password: $password){
			accessToken
		}
	}
`

const LoginPage = () => {
  	const router = useRouter()
    	
    const [accessToken, setaccessToken] = useRecoilState(accessTokenState)
    
	const [email, setEmail] = useState("")
	const [password, setPassword] = useState("")
	const [loginUser] = useMutation<Pick<IMutation, 'loginUser'>, IMutationLoginUserArgus>(LOGIN_USER)

	const onChangeEmail = (event: ChangeEvent<HTMLInputElement>) => {
		setEmail(event.target.value)
    }
    
	const onChangePassword = (event: ChangeEvent<HTMLInputElement>) => {
	    setPassword(event.target.value)
    }

	const onClickLogin = async() => {
        try{
          const result = await loginUser({
            variables: {
              email: email,
              password: password
            }
          })
          
          const accessToken = result.data?.loginUser.accessToken
          setAccessToken(accessToken)
          router.push('/loginsuccess')
        } catch(error) {
          // alert(error.message)을 사용하셔도 무방합니다.
          Modal.error({content: error.message})
        }
	} 

	return (
		<div>
			이메일 : <input type="text" onchange={onChangeEmail}/> <br/>
			비밀번호 : <input type="password" onchange={onChangePassword}/> 
			<button onClick={onClickLogin}>로그인하기!!</button>
		</div>
	)
}

export default LoginPage

이렇게 토큰을 같이 보내주도록 세팅을 하면, 유저의 정보를 받아와 화면에 “개발자님 환영합니다. ”와 같이 정보를 띄울 수 있게됩니다

// loginsuccess 폴더의 index.tsx
const FETCH_USER_LOGGED_IN = gql`
	query fetchUserLoggedIn {
		fetchUserLoggedIn {
			email
			name
		}
	}
`

const LoginSuccessPage = () => {
	const { data } = useQuery<Pick<IQuery, "fetchUserLoggedIn">>(FETCH_USER_LOGGED_IN)

	return(
		<div>
			{data?.fetchUserLoggedIn.name}님 환영합니다.
		</div>
	)
}

export default LoginSuccessPage

Recoil에 저장된 accessToken header 연동하기

// loginsuccess 폴더의 index.tsx
const FETCH_USER_LOGGED_IN = gql`
	query fetchUserLoggedIn {
		fetchUserLoggedIn {
			email
			name
		}
	}
`

const LoginSuccessPage = () => {
	const { data } = useQuery<Pick<IQuery,"fetchUserLoggedIn">>(FETCH_USER_LOGGED_IN)

	return(
		<div>
			{data?.fetchUserLoggedIn.name}님 환영합니다.
		</div>
	)
}

export default LoginSuccessPage

위의 uploadLink에 저 토큰을 추가했다는 것은 , 모든 컴포넌트에서 로그인 관련 토큰을 추가해서 보내주도록 만든 것 입니다.

로그인을 안한사람은 토큰 자리에 토큰이 없고, 로그인을 한 사람은 토큰을 가지고 있습니다.

그럼 저 토큰자리에 어떻게 토큰을 채워 줄 수 있을까요?

우리는 이전 시간에 global state를 배웠습니다.

즉, Recoil에 accessToken을 저장해두고 사용하고싶은 컴포넌트 전체를 감싸주고 필요한 곳에서 꺼내서 사용하는 것 입니다.

//app.tsx파일
import { RecoilRoot } from "recoil";

const MyApp = ({component, pageProps}: AppProps) => {
	return (
      <RecoilRoot>
        <ApolloSetting>
          <Global styles={globalStyles} />
          <Layout>
            <Component {...pageProps} />
          </Layout>
        </ApolloSetting>
      </RecoilRoot>
	)
}

export default MyApp
// src/components/commons/apollo/index.tsx

// Apollo Setting 빼주기
import { useRecoilState } from "recoil";
import { accessTokenState } from "../../../commons/store";

const ApolloSetting = (props) => {
	const [accessToken, setAccessToken] = useRecoilState(accessTokenState)

	const uploadLink = createUploadLink({
      	uri: "백엔드 주소",
      	headers: { Authorization: "Bearer 받아온 토큰" }
    })

	return (
		<ApolloProvider client={client}>
			{props.children}
		</ApolloProvider>
	)
}

export default ApolloSetting

0개의 댓글