사이드 프로젝트를 하기 시작했다. 거창한 이유는 없고 간단하게 만들고 싶은 것도 생겼고 겸사겸사 블로깅과 포트폴리오의 소재도 되기 때문에 꾸준히 해볼 생각이다.
그래서 무엇을 만드냐? 우선 만들 것은 '백오피스'다. 약간 의아할 수 있지만 백오피스를 만드는 이유는 다음과 같다.
위와 같은 이유로 백오피스를 만들기 시작한다. 그리고 첫 단추가 될 기능은 로그인, 로그아웃이다!
선택한 기술 스택은 아래와 같다.
Vue.js를 쓰는 이유는 익숙하기 때문이다. (Svelte를 써볼까 했는데 항상 작은 커뮤니티가 발목을 잡지 않을까 라는 생각에 이번에는 패스) Python과 FastAPI를 쓰는 이유는 개인적으로 선호하는 기술이기 때문이다. 적은 코드, 높은 생산성, FastAPI의 심플하지만 알찬 공식 문서까지 여러 가지 장점이 있다.
매우 일반적인 방식으로 아이디와 비밀번호 입력을 통한 로그인을 구현할 생각이다. (소셜 로그인 지원할 생각 없음) 로그인 요청을 보내는 프론트 코드는 아래처럼 작성했다.
async function login(): Promise<void> {
const response = await axios.post(`${import.meta.env.VITE_API_URL}login`, {
id: id.value,
password: sha256(password.value)
})
if (response?.data?.code === 200) {
saveTokens(response.data.data) // 리턴 받은 토큰(jwt)을 로컬 스토리지에 저장하는 메서드
await routerPush('/') // home 페이지로 이동
} else {
alert('아이디 또는 비밀번호가 일치하지 않습니다.')
}
}
...
export function saveTokens(jwt: JWTToken): void {
localStorage.setItem('accessToken', jwt.accessToken)
localStorage.setItem('refreshToken', jwt.refreshToken)
}
간단한 코드다. 아이디와 비밀번호(사용자가 입력한 비밀번호를 sha256으로 단방향 암호화한 값)를 서버에 보내고 정상적으로 처리되면 JWT를 리턴받아 로컬 스토리지에 저장한다. 로컬 스토리지에 저장하는 이유는 이후 API를 사용할 때 인가 처리를 하기 위해서다.
호출되는 서버 쪽 API는 아래와 같이 작성했다.
@router.post("/api/v1/login")
async def login(
data: LoginDTO, db: Session = Depends(get_db)
) -> CommonResponse[JWTResponse]:
admin = get_admin_by_login_id(db=db, login_id=data.login_id)
if not admin or not matches(data.password, admin.password):
return CommonResponse(code=400, message="아이디 혹은 비밀번호가 일치하지 않습니다.", data=None)
return CommonResponse(
data=JWTResponse(
access_token=create_access_token(admin.id),
refresh_token=create_refresh_token(admin.id),
)
)
서버 쪽 코드도 마찬가지로 간단하다. 입력받은 아이디와 비밀번호를 토대로 간단히 검증하고 이상이 없으면 액세스 토큰과 리프레숴 토큰을 리턴한다.
리턴 받은 토큰을 프론트에서는 로컬 스토리지에 저장해서 사용하고, 매 API 요청 시마다 'Authorization' 헤더에 액세스 토큰을 함께 보내게 된다. 매번 API 호출 시마다 헤더에 토큰을 지정해 주는 것은 상당히 비효율적이고 귀찮은 작업이니 axios의 interceptor를 활용했다.
const instance: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 5000
})
instance.interceptors.request.use(
(config) => {
const accessToken = getAccessToken()
config.headers['Content-Type'] = 'application/json'
config.headers['Authorization'] = `Bearer ${accessToken}` // Authorization 헤더에 액세스 토큰을 지정.
return config
},
(error) => {
console.log(error)
return Promise.reject(error)
}
)
위 axios의 인스턴스를 활용하는 API 호출은 이제 항상 Authorization 헤더에 액세스 토큰을 포함하게 된다.
그러면 이제 살펴볼 것은 프론트에서 API 호출 시 보내는 액세스 토큰을 검증하는 서버 쪽 로직이다. 간단히 아래와 같은 함수를 만들었다.
def verify_token(request: Request):
token = request.headers.get("Authorization")
if token is None:
raise UnauthorizedException
token = token.split(" ")[1]
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
id_: int = payload.get("id")
if not id_:
raise UnauthorizedException
return id_
except Exception:
raise UnauthorizedException
Authorization 헤더에 있는 액세스 토큰을 가져와서 jwt.decode()를 활용한 검증을 한다.(jwt.decode() 함수가 서명 및 토큰 기한 등을 검증한다) jwt.decode() 함수가 동작하는 동안 예외가 발생하면 서명이 잘못 됐다던지 혹은 토큰 기한이 만료가 됐다던지 등의 문제가 있다는 의미이고 그렇게 되면 UnauthorizedException 익셉션을 raise 하게 해놨다. (예외를 Exception으로 너무 포괄적으로 작성한 듯한 느낌이 있는 데 일단 중요한 부분은 아니라서 이렇게 하기로 했다)
이렇게 만든 함수는 아래처럼 사용할 수 있다.
# main.py
from router.admin import router as admin_router
from router.box import router as box_router
from router.login import router as login_router
from util.jwt import verify_token
app = FastAPI()
app.include_router(login_router)
app.include_router(admin_router, dependencies=[Depends(verify_token)]) # 라우터에 Depends 추가
라우터에 verify_token을 Depends 해주어 이제 해당 라우터의 요청마다 토큰을 검증하게 된다. (매우 간편하고 강력한 FastAPI의 DI 시스템인 Depends)
이제 리턴받은 토큰을 API 사용 시에 검증받는 부분까지 구현됐다. 로그아웃 기능 전에 추가로 액세스 토큰이 만료됐을 때 리프레쉬 토큰이 유효하다면 토큰을 재 발급받는 부분을 살펴보자.
// 프론트 코드
instance.interceptors.response.use(
async (response) => {
// ...
},
async (error) => {
if (error.response?.status === 401) { // Unauthorized!
let accessToken = getAccessToken()
if (!accessToken) {
await routerPush('/login')
return
}
if (isTokenExpired(accessToken)) {
await refreshTokens()
accessToken = getAccessToken()
}
error.config.headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`
}
return await axios.request(error.config)
}
return Promise.reject(error)
}
)
...
export async function refreshTokens(): Promise<void> {
const refreshToken = getRefreshToken()
if (!refreshToken || isTokenExpired(refreshToken)) {
await routerPush('/login')
}
const response = await axios.post(`${import.meta.env.VITE_API_URL}refresh`, {
refreshToken: refreshToken
})
saveTokens(response.data.data)
}
(instance.interceptors.request 부분에도 추가를 해야 하는데) 서버에서 401 에러를 반환하면 현재 액세스 토큰이 만료됐는지 확인하는 로직이 있고 만약 만료됐다면 refreshTokens() 함수가 동작한다. 해당 함수는 만약 리프레쉬 토큰이 유효하다면 토큰을 재 발급받는 API를 호출한다. 그리고 해당 API는 아래와 같다.
@router.post("/api/v1/refresh")
async def refresh(data: RefreshDTO) -> CommonResponse[JWTResponse]:
id_ = get_claims(data.refresh_token)["id"]
return CommonResponse(
data=JWTResponse(
access_token=create_access_token(id_),
refresh_token=create_refresh_token(id_),
)
)
...
def get_claims(token: str):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except Exception:
raise UnauthorizedException
특별한 내용은 없고 파라미터로 넘어온 리프레쉬 토큰에서 id를 추출한 후 해당 id로 다시 액세스 토큰과 리프레쉬 토큰을 발급한다. 정상적인 리프레쉬 토큰이 파라미터로 넘어왔으면 요청한 클라이언트는 새로운 액세스 토큰과 리프레쉬 토큰을 발급받아서 다시 정상적으로 API를 이용할 수 있다.
로그아웃도 다른 로직과 마찬가지로 간단하게 로컬 스토리지에 있는 토큰을 지워버리고 로그인 화면으로 라우팅하는 것으로 일단 마무리했다. (아주 간단한 로그아웃 처리)
export async function logout(): void {
removeTokens()
await routerPush('/login')
}
...
export function removeTokens(): void {
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
}
우선 이런 식으로 간단하게 로그인, 로그아웃 처리 관련 로직을 개발했다. 아직 개선할 점이 있지만 현재 내가 구축하는 백오피스 수준에서는 이 정도면 충분할 것 같다. 추가적인 개선 사항도 블로깅을 통해 기록해야겠다.