🔐

FastAPIで簡単にログイン認証が実装できた

2024/06/18に公開

最近flutter、fastapiを使って個人開発を始めました。

ログイン周りの実装に入ろうとしたところで、そういえば認証の仕組みについて曖昧な点があること、初めて触るフレームワークであること、記事にするのに丁度いい機会だと感じたので書いてみます。

概要

https://fastapi.tiangolo.com/ja/tutorial/security/
まずはfastapiの公式ドキュメントを読んで、コードを図にしてみました。(OAuth2.0で検索すると同じような図が出てきます。)どうやらfastapiでは許可サーバとリソースサーバが同じで、わざわざ許可サーバを別に用意する必要がなさそうです。
「パスワード(およびハッシュ化)によるOAuth2、JWTトークンによるBearer」とタイトルにある通り、認可にOAuth2、認証にBearer、トークン形式はJWT、パスワードはハッシュ化して保存する仕組みです。

ざっくりと説明すると、ログイン時にのみパスワードを使用して、以降のリクエストはアクセストークンをパスワード代わりに使用する仕組みです。

ポイント

ハッシュ化

ハッシュ化とは文字列を不規則な文字列に変換することです。もし第三者が不正にパスワードへアクセスしたとしても、悪用されるのを防ぐことができます。
不可逆性という特性を持つため、一度ハッシュ化すると平文に戻せません。
データベースのpasswordはハッシュ化して保存します。

// bcryptはハッシュ化アルゴリズムの一つ
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

// 平文とハッシュ済みパスワードの比較
def verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)

// 平文からハッシュ化の変換、ユーザ登録する際に使用します
def get_password_hash(password):
    return pwd_context.hash(password)
    
def authenticate_user(fake_db, username: str, password: str):
    user = get_user(fake_db, username)
    if not user:
        return False
    if not verify_password(password, user.hashed_password):
        return False
    return user

JWT

JWTとはjsonをエンコードしたトークンの仕様です。

// JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

subがユーザの識別子です。例ではusernameが使用されています。
expires_deltaで有効期限も指定できるようです。

access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
)

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

Bearer

OAuth 2.0ではよく使われる認証方式で、ヘッダーにアクセストークンを含めてAPIリクエストし、トークンが有効であればリクエストを処理します。
OAuth2PasswordBearerはRequestのAuthorization headerのtokenを取得します。

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

実際に動かしてみた

ここからは例のコードを参考にして、手元で動かしてみました。
まずはユーザ登録処理を作成してみました。

def get_password_hash(password):
    return pwd_context.hash(password)

@router.post("/signup", response_model=user_schema.UserCreateResponse)
def create_user(user_body: user_schema.UserCreate):
    user_body.password = get_password_hash(user_body.password)
    return user_db.create(user_body)

早速ログインしてみます。ログイン処理はこの辺り。
OAuth2PasswordBearerのtokenUrlは自由に設定可能なので/loginにしてみました。

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")

def authenticate_user(mail: str, password: str):
    user = user_db.findByMail(mail)
    if not user:
        return False
    if not verify_password(password, user.password):
        return False
    return user

@router.post("/login")
def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.id}, expires_delta=access_token_expires
    )

    return {"access_token": access_token, "token_type": "bearer"}

無事にログイン完了!

flutterからもログインしてアクセストークン取得までしてみます。

class UserLoginModel {
  final String username;
  final String password;

  UserLoginModel({required this.username, required this.password});

  Map<String, String> toBodyField() {
    return {
      'grant_type': 'password',
      'username': username,
      'password': password,
    };
  }
}

final response = await http.post(Uri.parse("http://127.0.0.1:8000/login"),
          headers: {
            'content-type': 'application/x-www-form-urlencoded',
          },
          body: UserLoginModel.toBodyField(),
          encoding: Encoding.getByName('utf-8'));

先ほどと同じユーザでログインしてみると、こちらもtokenが返却されログインできました!

Discussion