FastAPI - Login

홍찬우·2023년 7월 30일

Login code

Install

pip install python-jose[cryptography]

pip install passlib[bcrypt]

from fastapi import FastAPI, Depends, HTTPException, Form, Request, Response, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from jose import jwt, JWTError
from datetime import datetime, timedelta
import uvicorn

app = FastAPI()

# Jinja2 템플릿 설정
templates = Jinja2Templates(directory="./")

# JWT 설정
SECRET_KEY = "your-secret-key"  # 실제 사용 시 더 복잡한 값으로 변경해야 합니다.
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

# 사용자 정보 (실제로는 DB나 외부 저장소에서 가져와야 합니다.)
fake_users_db = {
    "test": {
        "username": "test",
        "password": "1234"
    },
    "test2": {
        "username": "test2",
        "password": "12345"
    }
}

# OAuth2PasswordBearer 객체를 사용하여 토큰을 가져올 수 있습니다.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

def create_access_token(data: dict, expires_delta: timedelta = None):
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

# 토큰 유효성 검사 함수
async def get_current_user(request: Request, token: str = Depends(oauth2_scheme)):
    try:
        token = request.cookies.get("access_token")
        if token is None:
            return None
        
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            return None
        return {"username": username}
    
    except JWTError:
        print('JWTError')

@app.post("/token")
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
    user = fake_users_db.get(form_data.username)
    if user is None or user["password"] != form_data.password:
        raise HTTPException(status_code=401, detail="Invalid credentials")
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(data={"sub": user["username"]}, expires_delta=access_token_expires)
    response = RedirectResponse(url="/secure-data/", status_code=status.HTTP_303_SEE_OTHER)
    response.set_cookie(
                        key='access_token',
                        value=access_token,
                        httponly=True
                    )
    
    return response

@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
    return templates.TemplateResponse("login.html", {"request": request})

@app.get("/secure-data/")
async def get_secure_data(request: Request):
    user = await get_current_user(request)
    if user is None:
        return {'message': 'login failed', 'user':user}
        
    return templates.TemplateResponse("secure.html", {"request": request, "user": user['username']})

@app.get("/logout")
async def logout(request: Request):
    response = templates.TemplateResponse("login.html", {"request":request})
    response.delete_cookie(key="access_token")
    return response

if __name__ == '__main__':
    uvicorn.run("login_test:app", host="127.0.0.1", port=8000, reload=True)

secure-data endpoint

  • 로그인 된 상태로 다른 페이지에 넘어갔을 때, 로그인 정보를 잘 저장한 채 넘어가지는지

  • 로그인하지 않고 secure-data에 접근했을 때, login fail을 반환하는지 체크

    • 로그인 후, secure-data에 username 정보가 잘 나타나는 것 확인

    • 하지만 그 후 코드 실행 종료하고, 로그인하지 않고 secure-data에 접근하자 username 정보가 또 뜨는 것을 확인

    • 혹시 쿠키가 브라우저에 계속 남아있는 것이 아닌가 하여 다른 브라우저에서 로그인하지 않고 secure-data 접근하니 login fail 반환

      • 쿠키가 코드 실행을 종료해도 계속 남아있는 것으로 판단

쿠키란?

  • chatgpt 피셜

  • 맨 마지막 문단 내용처럼 로그인 성공하면 매번 재로그인할 필요 없이 상태 유지 가능


@app.post("/token")

  • oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 에서 (tokenUrl = “token”) 부분이 ‘/token’ Url 접근을 의미

  • /token 엔드포인트에선 사용자 인증, 액세스 토큰 발급 및 반환과 같은 작업을 처리한다.


get_current_user()

  • secure-data처럼 인증 보호가 된 엔드포인트에 접근할 때, 현재 토큰의 유효성을 검사하는 함수

    • 이 때 jwt.decode() 함수와 SECRET_KEY, ALGORITHM 정보를 참조해 검사
  • 토큰이 유효하지 않거나 만료된 경우 Error raise


로그인 정보를 유지한 채 다른 페이지 이동?

user = await get_current_user(request)
    if user is None:
        return {'message': 'login failed'}
  • get_current_user() 함수를 await하며 user 정보를 받고, 만약 None을 받는다면(user 정보가 없거나 만료된 토큰) 예외 처리

문제

  • 현재 코드는 로그인 성공하면 html의 form action을 통해 /token 엔드포인트로 이동

    • form action을 secure-data 페이지로 넘기면 token 엔드포인트를 거치지 않아 인증 과정을 수행하지 못하고, 로그인에 실패함
    • 어떻게 /token으로 이동하지 않고 인증 성공 & 페이지 이동까지 할 수 있을까?
  • 이전 코드

    @app.post("/token")
    async def login_for_access_token(response: Response, form_data: OAuth2PasswordRequestForm = Depends()):
        user = fake_users_db.get(form_data.username)
        if user is None or user["password"] != form_data.password:
            raise HTTPException(status_code=401, detail="Invalid credentials")
        access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
        access_token = create_access_token(data={"sub": user["username"]}, expires_delta=access_token_expires)
        response.set_cookie(
                            key='access_token',
                            value=access_token,
                            httponly=True
                        )
        return RedirectResponse(url="/secure-data/", status_code=status.HTTP_303_SEE_OTHER)
    • 이전엔 함수의 파라미터로 response를 받도록 사용

      • 아마도 cookie 정보를 제대로 넘기지 못하는듯 함

  • 변경 후
    @app.post("/token")
    async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
        user = fake_users_db.get(form_data.username)
        if user is None or user["password"] != form_data.password:
            raise HTTPException(status_code=401, detail="Invalid credentials")
        access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
        access_token = create_access_token(data={"sub": user["username"]}, expires_delta=access_token_expires)
        response = RedirectResponse(url="/secure-data/", status_code=status.HTTP_303_SEE_OTHER)
        response.set_cookie(
                            key='access_token',
                            value=access_token,
                            httponly=True
                        )
        
        return response
    • response를 함수에서 받는 것이 아닌 새로 만들어주고, 쿠키 세팅한 채로 return

      • login이 성공하면 secure-data로 잘 넘어가는 것을 확인

Response는 뭘까?

  • HTTP 응답을 나타내는 클래스

    • HTTP 응답의 상태 코드, 헤더, 콘텐츠 등을 설정
  • FastAPI에서는 Response 객체를 반환하는 것으로 해당 응답을 클라이언트에게 전송

    • JSON, HTML, 파일 등 다양한 응답 유형을 자동으로 처리

    • 자동으로 생성할 땐 직접 Response 객체를 만들 필요가 없음

  • 특별한 응답을 생성하거나 상태 코드, 헤더 등을 변경해야 할 때는 Response 객체를 사용

# 자동 생성 된 response를 받는 경우
@app.get("/custom-response")
async def custom_response():
    # Response 객체를 생성하여 커스텀 응답 생성
    content = "This is a custom response."
    headers = {"Custom-Header": "Value"}
    response = Response(content, status_code=200, headers=headers)
    return response
# 자동 생성 response를 받지 않고 내가 직접 생성
@app.get("/custom-response")
async def custom_response():
    # Response 객체를 생성하여 커스텀 응답 생성
    content = "This is a custom response."
    headers = {"Custom-Header": "Value"}
    response = Response(content, status_code=200, headers=headers)
    return response

Logout

Python

@app.get("/logout")
async def logout(request: Request):
    response = templates.TemplateResponse("login.html", {"request":request})
    response.delete_cookie(key="access_token")
    return response

HTML

<!DOCTYPE html>
<html>
<head>
    <title>Secure Data</title>
</head>
<body>
    <h1>Secure Data</h1>
    <p>{{user}}</p>
    <button id="logout-btn">Logout</button>

    <script>
        document.getElementById("logout-btn").addEventListener("click", () => {
            // 로컬 스토리지에서 토큰 삭제
            localStorage.removeItem("access_token");
            // 로그아웃 후 로그인 페이지로 이동
            window.location.href = "/logout";
        });
    </script>
</body>
</html>
  • 로그아웃 버튼을 클릭하면, 로그인 페이지로 다시 넘어감

    • 쿠키 정보를 모두 삭제하기 때문에 다시 로그인하지 않은 채로 엔드포인트에 접근하면 불가능하도록 만듦
  • login 엔드포인트와 동일하게 맞추고 싶었지만 힘들어 보임


Using SQLite

login에서 sqlite 연동

db = SessionLocal()
user = get_user(db, form_data.username)
  • 기존 딕셔너리 타입인 fake_db를 db로 변경
  • get_user 은 crud.py에서 유저의 (user_id, password) 를 가져옴

signup 등록

@app.get("/signup")
def get_signup_form(request: Request):
    return templates.TemplateResponse("login.html", context={"request": request})

@app.post("/signup")
def login(username: str = Form(...), password: str = Form(...)):
    db = SessionLocal()
    user_info = schemas.User(user_id=username, password=password)
    new_user = create_user(db, user_info)
    return {"user_id": username, "user_list": get_users(db)}

secure-data ⇒ home.html

  • login.html에서 form action=’/home’ 변경
  • home path operation에 auth 검증 코드 추가

모든 함수에 login authentication 적용

user = await get_current_user(request)
if user is None:
    return {'message': 'login failed', 'user':user}
user_id = user['username']
  • 모든 함수에 위 코드 및 함수 파라미터테 request: Request 추가

  • 현재 프론트가 안 되어 있어 swagger에서 실험 가능

    • 로그인한 상태로 execute 하면 return 잘 반환

    • 로그아웃 후 execute 하면 failed 잘 반환

profile
AI-Kid

0개의 댓글