전 시간엔 로그인 기본 개념에 대해서 알았다. 권한을 부여하는 것. 권한을 부여하기위해 웹에선 어떤 걸 통행권으로서 지급할까?
토큰을 만드는 기술들은 여러가지가 있다. 그 중 웹에서 JWT가 거의 규격처럼 받아들여진다.
JSON Web Token, 웹에서 JSON으로 암호화된 토큰
참고: JWT 사이트여기를 가면 헤더, 페이로드, 시그니처 이렇게 3가지로 나뉘어서 보여준다.
암호화 규칙(alg), 토큰 타입
토큰의 핵심 데이터, 클레임
토큰의 내용, 다루려면 조심해야한다. 정보가 다 들어가 있으니!
암호화를 위한 데이터
토큰이 올바르게 사용됐는지 시크릿키 및 작성 방법(인코딩), 해싱 방법 등이 담겨져있음
즉, 해시함수(헤더+페이로드+해싱 시크릿키)로 토큰의 값을 정한다는 것이다. 갑자기 나온 해시함수는 무엇일까?
토큰을 암호화하는 방식은 여러가지가 있다.
토큰을 암호화하는 방식, 단방향으로만 암호화된다.
위 헤더에선 HS256
으로 해싱 알고리즘을 써서 암호화하고 있다. 그러면 복호화가 안되는 건가? 바로 이때 시크릿 키가 필요하다.
이런 식으로 암호화하고,
이런 식으로 유효성 검사를 하여 올바른 토큰인지 검증한다.
위 사진은 서비스 관점에서 토큰이 어떻게 이루어지는지 프론트를 중점적으로 기술해 놓았다.
저런 식으로 유저가 요청을 하면 유효성 검사나 토큰을 서버와 주고 받고 로그인 및 권한을 주는 것이다.
1일차에서 구성한 화면에 제공된 로그인 API를 붙여볼 것이다.
API 사용 방법은 이러하다.
URL: ‘http://wanted-p2.bluestragglr.com/auth/login’
body: { username*: string, password*: string }
response: { access_token: string }
URL: ‘http://wanted-p2.bluestragglr.com/profile’
Header에 Authorzation: `Bearer ${access_token}` 포함
response: { userId: number, userInfo: { name: string }, username: string }
전 코드에 api/login.ts
를 만들어 api를 연결해보자!
import React, { useState } from 'react'
import { getCurrentUserInfoWithToken, loginWithToken } from '../../api/login'
import { UserInfo } from '../../types/user'
const JWTLogin = () => {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
const loginSubmitHandler = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget)
const loginPayload = {
username: formData.get('username') as string,
password: formData.get('password') as string
}
// TODO: 로그인 연결 및 토큰 가져오기 (loginWithToken 함수 사용)
// 로그인 실패시 함수를 종료합니다.
// 로그인 성공시, getCurrentUserInfoWithToken 함수를 호출하여 userInfo를 가져옵니다.
// TODO: 유저 정보 가져오기 (getCurrentUserInfoWithToken 함수 사용)
// 유저 정보 가져오기 실패시 함수를 종료합니다.
// 유저 정보 가져오기 성공시, userInfo 상태를 업데이트합니다.
}
// ...전과 같은 tsx 코드
export default JWTLogin
import { BASE_URL } from './const'
import { getAccessTokenFromLocalStorage, saveAccessTokenToLocalStorage } from '../utils/accessTokenHandler'
import { UserInfo } from '../types/user'
type LoginResult = 'success' | 'fail'
export type LoginResultWithToken = {
result: 'success'
access_token: string
} | {
result: 'fail'
access_token: null
}
export interface LoginRequest {
username: string
password: string
}
/*********
* 실습 2-1
* */
export const loginWithToken = async (args: LoginRequest): Promise<LoginResultWithToken> => {
// TODO(2-1): 로그인 API 호출 및 토큰 반환하기
// POST, `${ BASE_URL }/auth/login`을 호출하세요.
// API Spec은 강의 자료를 참고하세요.
// access_token 발급에 성공한 경우에는 { result: 'success', access_token: string } 형태의 값을 반환하세요.
return {
result: 'fail',
access_token: null
}
}
export const getCurrentUserInfoWithToken = async (token: string): Promise<UserInfo | null> => {
// TODO(2-1): 함수에서 토큰을 직접 주입받아 사용하기
// GET, `${ BASE_URL }/profile`을 호출하세요.
// argument로 전달받은 token을 Authorization header에 Bearer token으로 넣어주세요.
// API Spec은 강의 자료를 참고하세요.
// 유저 정보 조회에 성공한 경우에는 UserInfo 타입의 값을 반환하세요.
return null
}
/*********
* 실습 2-2
* */
//... 2-2는 나중에
api는 알맞은 엔드포인트에, URL도 넣어서 해줬다. 로그인은 username
, password
2개를 인수로 POST 요청을 보내 access_token
를 받아왔다.
export const loginWithToken = async (args: LoginRequest): Promise<LoginResultWithToken> => {
// TODO(2-1): 로그인 API 호출 및 토큰 반환하기
// POST, `${ BASE_URL }/auth/login`을 호출하세요.
// API Spec은 강의 자료를 참고하세요.
// access_token 발급에 성공한 경우에는 { result: 'success', access_token: string } 형태의 값을 반환하세요.
const loginResponse = await fetch(`${BASE_URL}/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(args),
});
const loginResponseData = await loginResponse.json();
if (loginResponse.ok) {
return {
result: "success",
access_token: loginResponseData.access_token,
};
}
return {
result: "fail",
access_token: null,
};
};
알게된 점은 loginResponse.ok
로 조건을 세워서 분기를 나눴다는 거다. 이렇게 사용할 수 있구나 싶었다. try, catch문과 비슷하면서 if로도 표현할 수 있구나 싶었다.
그 다음 getCurrentUserInfoWithToken
은 받아온 토큰을 가져와 헤더에 넣고 loginResult
를 받아와 파싱하고 userInfo
를 뽑아낸다.
export const getCurrentUserInfoWithToken = async (token: string): Promise<UserInfo | null> => {
// TODO(2-1): 함수에서 토큰을 직접 주입받아 사용하기
// GET, `${ BASE_URL }/profile`을 호출하세요.
// argument로 전달받은 token을 Authorization header에 Bearer token으로 넣어주세요.
// API Spec은 강의 자료를 참고하세요.
// 유저 정보 조회에 성공한 경우에는 UserInfo 타입의 값을 반환하세요.
const loginResult = await fetch(`${BASE_URL}/profile`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const loginResultData = await loginResult.json();
return loginResultData.userInfo;
};
그 다음 formData
가 있는 곳에 api 함수들을 가져와 form
에 입력한 값을 인수로 넣어주고, 그 결과값을 getCurrentUserInfoWithToken
의 인수에, 그 결과를 상태로 넣어줬다.
const loginSubmitHandler = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const loginPayload = {
username: formData.get("username") as string,
password: formData.get("password") as string,
};
// TODO: 로그인 연결 및 토큰 가져오기 (loginWithToken 함수 사용)
// 로그인 실패시 함수를 종료합니다.
// 로그인 성공시, getCurrentUserInfoWithToken 함수를 호출하여 userInfo를 가져옵니다.
const loginResult = await loginWithToken(loginPayload);
if (loginResult.result === "fail") return;
// TODO: 유저 정보 가져오기 (getCurrentUserInfoWithToken 함수 사용)
// 유저 정보 가져오기 실패시 함수를 종료합니다.
// 유저 정보 가져오기 성공시, userInfo 상태를 업데이트합니다.
const userInfo = await getCurrentUserInfoWithToken(loginResult.access_token);
if (userInfo === null) return;
setUserInfo(userInfo);
};
완성! 당연히 axios도 가능하다!
import axios from "axios";
export const loginWithToken = async (args: LoginRequest): Promise<LoginResultWithToken> => {
try {
const response = await axios.post(`${BASE_URL}/auth/login`, args, {
headers: {
"Content-Type": "application/json",
},
});
return {
result: "success",
access_token: response.data.access_token,
};
} catch (error) {
console.error(error);
return {
result: "fail",
access_token: null,
};
}
};
이런 식으로 하면 된다.
아까는 토큰을 반환해서 가져와 유저정보를 가져왔다. 이제 그러지 않고 로컬스토리지에 저장하고, 다른페이지에서 유저정보를 가져올 수 있게 하는 식으로 한다.
이번엔 로컬스토리지를 사용하기에 로컬스토리지를 저장하는 함수와 읽는 함수를 2개 만들었다.
export const saveAccessTokenToLocalStorage = (accessToken: string) => {
localStorage.setItem('accessToken', accessToken)
}
export const getAccessTokenFromLocalStorage = (): string => {
return localStorage.getItem('accessToken') || ''
}
import React, { useState } from 'react'
import { getCurrentUserInfo, login } from '../../api/login'
import { UserInfo } from '../../types/user'
const JWTLoginWithLocalStorage = () => {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
const loginSubmitHandler = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget)
const loginPayload = {
username: formData.get('username') as string,
password: formData.get('password') as string
}
// TODO: 로그인 연결 및 토큰 가져오기 (login 함수 사용)
// 로그인 실패시 함수를 종료합니다. 토큰은 login 함수 안에서 localStorage에 저장되도록 구현합니다.
// TODO: 유저 정보 가져오기 (getCurrentUserInfo 함수 사용)
// 유저 정보 가져오기 실패시 함수를 종료합니다.
// 유저 정보 가져오기 성공시, userInfo 상태를 업데이트합니다.
}
return (<div>
// ...똑같
</div>)
}
export default JWTLoginWithLocalStorage
export const login = async (args: LoginRequest): Promise<LoginResult> => {
// TODO(2-2): 로그인 API 호출 및 access token 로컬스토리지에 저장하기
// POST, `${ BASE_URL }/auth/login`을 호출하세요.
// API Spec은 강의 자료를 참고하세요.
// access_token 발급에 성공한 경우에는 saveAccessTokenToLocalStorage 함수를 호출하여 access_token을 localStorage에 저장하고 'success'를 반환하세요.
return "fail";
};
export const getCurrentUserInfo = async (): Promise<UserInfo | null> => {
// TODO(2-2): 로컬스토리지에서 토큰을 가져와 사용하기
// GET, `${ BASE_URL }/profile`을 호출하세요.
// 로컬 스토리지에 있는 token을 getAccessTokenFromLocalStorage로 가져와서 Authorization header에 Bearer token으로 넣어주세요.
// API Spec은 강의 자료를 참고하세요.
// 유저 정보 조회에 성공한 경우에는 UserInfo 타입의 값을 반환하세요.
return null;
};
다른 페이지도 로컬스토리지에 저장된 토큰을 이용해 유저정보를 get해올줄 알아야한다.
import React, { useCallback, useEffect, useRef, useState } from "react";
import { getCurrentUserInfo, login } from "../../api/login";
import { UserInfo } from "../../types/user";
const AutoLogin = () => {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const isDataFetched = useRef(false);
const getUserInfo = useCallback(async () => {
// TODO: 유저 정보 가져오기 (getCurrentUserInfo 함수 사용)
// getCurrentUserInfo 함수를 이용해 유저 정보를 가져온 후, setUserInfo 함수를 이용해 userInfo 상태를 업데이트해주세요.
isDataFetched.current = true;
}, []);
useEffect(() => {
if (isDataFetched.current) return;
getUserInfo();
}, []);
return (
<div>
<h1>Another page</h1>
<div>
<h2>User info</h2>
{JSON.stringify(userInfo)}
</div>
</div>
);
};
export default AutoLogin;
api 코드는 아래와 같다.
export const login = async (args: LoginRequest): Promise<LoginResult> => {
// TODO(2-2): 로그인 API 호출 및 access token 로컬스토리지에 저장하기
// POST, `${ BASE_URL }/auth/login`을 호출하세요.
// API Spec은 강의 자료를 참고하세요.
// access_token 발급에 성공한 경우에는 saveAccessTokenToLocalStorage 함수를 호출하여 access_token을 localStorage에 저장하고 'success'를 반환하세요.
const loginResponse = await fetch(`${BASE_URL}/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(args),
});
const loginResponseData = await loginResponse.json();
saveAccessTokenToLocalStorage(loginResponseData.access_token);
if (loginResponse.ok) {
return "success";
}
return "fail";
};
export const getCurrentUserInfo = async (): Promise<UserInfo | null> => {
// TODO(2-2): 로컬스토리지에서 토큰을 가져와 사용하기
// GET, `${ BASE_URL }/profile`을 호출하세요.
// 로컬 스토리지에 있는 token을 getAccessTokenFromLocalStorage로 가져와서 Authorization header에 Bearer token으로 넣어주세요.
// API Spec은 강의 자료를 참고하세요.
// 유저 정보 조회에 성공한 경우에는 UserInfo 타입의 값을 반환하세요.
const userInfoRes = await fetch(`${BASE_URL}/profile`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getAccessTokenFromLocalStorage()}`,
},
});
if (userInfoRes.ok) {
return userInfoRes.json() as Promise<UserInfo>;
}
return null;
};
formData
가 있는 페이지는 똑같다. 대신
...
// TODO: 로그인 연결 및 토큰 가져오기 (login 함수 사용)
// 로그인 실패시 함수를 종료합니다. 토큰은 login 함수 안에서 localStorage에 저장되도록 구현합니다.
const loginResult = await login(loginPayload);
if (loginResult === "fail") return;
// TODO: 유저 정보 가져오기 (getCurrentUserInfo 함수 사용)
// 유저 정보 가져오기 실패시 함수를 종료합니다.
// 유저 정보 가져오기 성공시, userInfo 상태를 업데이트합니다.
const userInfo = await getCurrentUserInfo();
if (userInfo === null) return;
setUserInfo(userInfo);
...
위 서비스를 전개하면서 보안상 문제를 맞닥트릴 수 있다. 알아보자
당연히 시크릿 키가 누출되면 안된다.
그래서 페이로드 등에 누출되면 안되는 정보들(시크릿키 등)은 담기면 안된다.
토큰 자체를 탈취당한다는 뜻은 신용카드를 그냥 가져갔다는 얘기다. 그래서 노출이 되면 안된다. 보통 막기 위해 토큰에 만료기간을 두는 편이다.
1번 2번은 백엔드가 주로 담당하기에 프론트에서는 뭔가 도입할 것이 없다. 그런데 3번은 좀 다르다.
3번은 브라우저에서 토큰이 노출되거나, 해커가 XSS(인풋에 스크립트 코드를 넣어 명령), CSRF 등의 공격으로 토큰을 복사 및 재사용해 보안을 뚫을 수 있기 때문이다.
참고: CSRF
그러면 우리는 어떤 기술을 사용해서 보안을 강화할 수 있을까? 액세스 토큰을 사용하는 것이 아닌 바로 리프레쉬 토큰을 사용하는 것이다. 참고: 리프레쉬란? SPA = Single-Page Application, AS = Authorization Server, RS = Resource Server, AT = Access Token, RT = Refresh Token
이런 식으로 시스템 설계를 도입하는 것이다. 액세스 자체의 권한을 줄이면서, 리프레쉬로만 발급을 받게 해 보안을 지키는 것이다.
근데 여기서 문제가 또 생긴다.아까 말했던 것처럼 XSS(Cross Site Scripting)로 권한을 받고, 어디 스토리지에 저장해놓은 액세스 토큰을 탈취해 명령을 내릴 수 있다. 뭐냐? 아까랑 똑같지 않느냐? 그래서 HttpOnly Cookie가 나온 것이다.
쿠키는 JS로 접근이 가능하다. 그래서 접근이 불가능한 쿠키를 만들었다.
HttpOnly Cookie는 브라우저에서 접근이 불가능한 쿠키를 설정한 것이다.
참고로 메모리에 넣는다는 건 상태에 넣어서 일시적으로 사용한다는 것(리코일, 리덕스 등)이다.
토큰 발급이 끝나면 메모리에 있던 토큰이 없어지면서 JS로 접근할 수 없는 토큰이 생겨서 해커들이 접근할 방도가 없는 것이다. 그렇게 된다면 XSS 등으로 접근할 수 없다.
해킹의 방도는 끝도 없나보다. CSRF라는게 또 나왔는데 간단히 말해서 어느 웹 이용자의 환경에 피싱 사이트를 들어가게 해서 권한을 얻고, 사용자의 의도와 무관하게 공격자의 의도대로 행동하게 만드는 것이다.
이거는 권한 자체가 사용자의 권한을 이용하니 JS코드로 토큰을 얻지 않아도(XSS) 알맞은 권한과 함께 위조된 요청을 인증서버에 넘겨 의도된 행동을 유도해 공격자의 의도대로 공격을 할 수 있다.
어떻게 해결해야할까? 다양한 방법이 있다. 라이브러리를 사용하든, SameSite쿠키를 사용하든, referer 정책을 사용하든 해결점은 있다.
강의에서 들은 건 CSRF 토큰 사용해 각 요청에 포함되는 고유한 토큰을 사용하여 이 요청의 사용자가 같은지 다른지 서버에서 확인하는 방식을 배웠다.토큰을 통해 사용자가 다르면 토큰 무효화가되고 다시 로그인하라고 리다이렉트를 해서 방지한다. 누가 로그인했습니다 하면서 로그아웃되는 원리랑 같은 거다!
와,, 방대한 양의 정리... 잘보고갑니다 !!