Svelte+FastAPIでSign in with Googleを試してみた

目次

はじめに

SvelteとFastAPIを使用して構築したサンプルウェブサイトにGoogleのサインイン機能を実装しました。 Google Sign Inに成功した後、バックエンドのAPIサーバーにログインするためには、様々な方法が考えられます。 Googleから受け取ったJWTを、RequestヘッダにAuthorization: "Bearer: JWT"として送信し、正しいJWTであれば、認証されます。 また、バックエンドでJWTを発行し、Authorizationヘッダにセットして認証済みユーザーを識別する方法も一般的です。 しかし、JWTをそのままログインユーザーの識別に利用する場合、JWTの漏洩時に即時の無効化が難しい問題があります。 参考:Stop using JWT for sessions。 そこで、GoogleからJWTを受け取った後、FastAPI側で新たにsession_idを発行し、Cookieを介してセッションを維持する方法で実装しました。

セッション情報はFastAPIのセッションデータベースで管理されており、管理者がいつでもセッションを無効にできます。 また、CookieにSecure属性とHttpOnly属性を付与することで、経路での盗聴防止やJavaScriptからのアクセス防止が可能になり、より安全なWebサイトの構築が可能です。

なお、SvelteもFastAPIも独学で学習中ですので、おかしな点があれば、アドバイスいただけると嬉しいです。

実装するもの

認証が実装されると、未ログイン時のアクセスはログインページにリダイレクトされ、そこでGoogleアカウントでログインできます。

Login page

Customerページは、認証に成功した場合にのみ表示できます。

Customer page for authenticated users

FastAPIではSwagger UIによるドキュメントページが自動生成されます。

FastAPI OpenAPI doc page

Svelteでのフロントエンドの実装

Svelteを使用してフロントエンドを実装します。 バックエンドからcustomerデータを取得し、テーブル表示するページにGoogle OAuth2を利用した認証機能を実装します。

Google Sign Inに成功し、取得したJWTをバックエンドのAPIサーバーに送信します。 バックエンド側では、JWTをベリファイしユーザーアカウントを作成し、session_idをCookieにセットしてレスポンスを返信します。 これ以降、バックエンドへのリクエスト時には、常にCookieにsession_idをセットして送信します。

実装したコードは以下のリポジトリにあります。

ログイン機能の実装ポイントを以下に説明します。

ルーティング

svelete-routingを利用し、以下のようにルーティングを設定します。

  • /customer: Customerコンポーネントを表示します。
  • /login: LoginPageコンポーネントを表示します。

App.svelteのサンプルコードは次の通りです。

<script>
  import { Router, Link, Route } from "svelte-routing";
  import Top from "./components/Top.svelte";
  import Customer from "./components/Customer.svelte";
  import NoMatch from "./components/NoMatch.svelte";
  import LoginPage from "./components/LoginPage.svelte";

  export let url = "";
</script>

<div class="container-sm">
  <Router {url}>
    <nav>
      <table class="table-borderless table-responsive">
        <tbody>
          <tr><td><Link to="/">Top</Link></td></tr>
          <tr><td><Link to="/customer">Customer</Link></td></tr>
        </tbody>
      </table>
    </nav>

    <div>
      <Route path="/"><Top /></Route>
      <Route path="/customer"><Customer /></Route>
      <Route path="/login"><LoginPage /></Route>
      <Route path="*"><NoMatch /></Route>
    </div>
  </Router>
</div>

ログインページ

GoogleのSign Inボタンを表示し、OneTapインターフェースも利用します。 GoogleでSign In後、コールバックファンクションbackendAuthを呼び出します。 backendAuthでは、Google Sign Inで得られたレスポンスをhttp://localhost/api/loginに送信します。 レスポンスにはJWTトークンが含まれます。 バックエンドでのログインが成功した場合、直前にいたページにリダイレクトします。 失敗した場合、エラー処理が行われ、再度ログインページにリダイレクトされます。

LoginPage.svelteのサンプルコードは次の通りです。

<script>
  import { onMount } from "svelte";
  import { apiAxios } from "../lib/apiAxios";
  import { useLocation, navigate } from "svelte-routing";
  import { jwtDecode } from "jwt-decode";

  let location = useLocation();
  let origin = $location.state?.from;

  const backendAuth = (response) => {
    const data = JSON.stringify(response, null, 2);
    console.log("JWT fed to backendAuth:\n", data);

    apiAxios
      .post(`/api/login/`, data)
      .then((res) => {
        console.log("Navigate back to: ", origin);
        navigate(origin, { replace: true });
      })
      .catch((error) => {
        console.log("backendAuth failed. Redirecting to /login... ");
      });
  };
  const onLogin = backendAuth;

  onMount(() => {

    google.accounts.id.initialize({
      /* global google */
      client_id: import.meta.env.VITE_APP_GOOGLE_OAUTH2_CLIENT_ID,
      callback: (r) => onLogin(r),
      ux_mode: "popup",
      //        ux_mode: "redirect",
    });

    google.accounts.id.renderButton(document.getElementById("signInDiv"), {
      theme: "filled_blue",
      size: "large",
      shape: "circle",
    });

    google.accounts.id.prompt();
  });
</script>

<main>
  <h2>Login page</h2>
  <div id="signInDiv"></div>
</main>

axiosインスタンスのセットアップ

withCredentials: trueをセットすることでaxiosはCookieを送信するようになります。 axiosのinterceptorsでエラー処理を行い、バックエンドから401 Unauthorized403 Forbiddenが返ってきた場合、/loginへリダイレクトします。

apiAxios.jsのサンプルコードは次の通りです。

import axios from "axios";
import { navigate } from "svelte-routing";

export const apiAxios = axios.create({
  baseURL: `${import.meta.env.VITE_APP_API_SERVER}`,
  withCredentials: true,
});

apiAxios.interceptors.response.use(
  (response) => {
    return response;
  },
  (error) => {
    if (error.response.status === 401 || error.response.status === 403) {
      console.log(
        "apiAxios failed. Redirecting to /login... from",
        location.pathname
      );
      navigate("/login", { state: { from: location.pathname }, replace: true });
    }
    return Promise.reject(error);
  }
);

LogoutButtonコンポーネント

Logoutボタンを表示するコンポーネントです。 onMount時に、バックエンドサーバにアクセスし、ログインしているユーザーの情報を取得します。 Cookieにsession_idが無い場合、すなわち未ログインの場合にはユーザー情報取得に失敗し、apiAxios.interceptorのエラー処理により、/loginページにリダイレクトされます。

<script>
  import { onMount } from "svelte";
  import { apiAxios } from "../lib/apiAxios.js";

  let user;

  onMount(() => {
    console.log("Logout Component Mounted");
    getUser();
  });

  const handleLogout = () => {
    user = null;
    apiAxios
      .get(`/api/logout/`)
      .then((res) => {
        console.log("backendLogout", res);
        getUser();
      })
      .catch((error) => console.log("Logout failed: ", error));
  };

  const getUser = () => {
    apiAxios
      .get(`/api/user/`)
      .then((res) => {
        user = res.data;
        console.log("getUser: user:", user);
      })
      .catch((error) => console.log("getUser failed: ", error.response));
  };

  const onLogout = handleLogout;
</script>

<div>
  Authenticated as {user?.username} &nbsp;
  <button type="button" on:click={onLogout}>Sign Out</button>
</div>

Customerコンポーネント

バックエンドサーバからデータを取得し、テーブル表示するコンポーネントです。LogoutButton コンポーネントがページ内に配置されているので、未ログインの場合には、/login ページにリダイレクトされます。

<script>
  import { onMount } from "svelte";
  import { apiAxios } from "../lib/apiAxios";
  import LogoutButton from "./LogoutButton.svelte";

  let customers = [];
  let Loading = true;

  onMount(async () => {
    await new Promise((r) => setTimeout(r, 1000));
    apiAxios
      .get(`/api/customer/`)
      .then((res) => (customers = res.data.results))
      .catch((error) => console.log(error))
      .finally(() => Loading = false);
  });
</script>

<LogoutButton />
<h2>This is Customer.</h2>

{#if Loading}
  <p>Loading ...</p>
{:else}
  <div class="table-responsive">
    <table class="table table-bordered table-hover table-striped">
      <thead class="table-light">
        <tr>
          <th>id</th>
          <th>name</th>
          <th>email</th>
        </tr>
      </thead>
      <tbody>
        {#each customers as cs}
          <tr>
            <td>{cs.id}</td>
            <td>{cs.name}</td>
            <td>{cs.email}</td>
          </tr>
        {/each}
      </tbody>
    </table>
  </div>
{/if}

FastAPIでのバックエンド実装

FastAPIを使用して、バックエンドのAPIサーバを実装します。 フロントエンドから受け取ったJWTを検証し、ユーザーアカウントを作成して、session_idを発行しセッションデータベースに登録します。 作成したsession_idをCookieにセットしてレスポンスを返信します。 受け取ったJWTに対応するユーザーがデータベースに存在しない場合、新たにユーザーを作成します。

認証で保護されたエンドポイントへのリクエストを受け取った場合、Cookieにセットされたsession_idとセッションデータベースを照合し、有効なセッション情報が存在している場合のみ、要求されたデータを返信します。

実装したコードは以下のリポジトリにあります。

ログイン機能の実装ポイントについて以下に説明します。

/api/loginエンドポイント

フロントエンドからJWTを受け取り、Googleの公開証明書を使用してJWTを検証します。 検証に成功すると、JWT内のemailアドレスを使用してユーザーデータベースにユーザーを登録します。 新しく作成したユーザーの情報とsession_idをセッションデータベースに登録し、Cookieにsession_idをセットしてレスポンスを返します。

auth/auth.py

async def VerifyToken(jwt: str):
    try:
        idinfo = id_token.verify_oauth2_token(
            jwt,
            requests.Request(),
            settings.google_oauth2_client_id)
    except ValueError:
        print("Error: Failed to validate JWT token with GOOGLE_OAUTH2_CLIENT_ID=" + settings.google_oauth2_client_id +".")
        return None

    print("idinfo: ", idinfo)
    return idinfo

@router.post("/login")
async def login(request: Request, response: Response, ds: Session = Depends(get_db), cs: Session = Depends(get_cache)):
    body = await request.body()
    jwt = json.loads(body)["credential"]
    if jwt == None:
        return  Response("Error: No JWT found")
    print("JWT token: " + jwt)

    idinfo = await VerifyToken(jwt)
    if not idinfo:
        print("Error: Failed to validate JWT token")
        return  Response("Error: Failed to validate JWT token")

    user = await GetOrCreateUser(idinfo, ds)

    if user:
        user_dict = get_user_by_name(user.name, ds)
        if not user_dict:
            raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail="Error: User not exist in User table in DB.")
        user = UserBase(**user_dict)
        session_id = create_session(user, cs)
        response.set_cookie(
            key="session_id",
            value=session_id,
            httponly=True,
            max_age=1800,
            expires=1800,
        )
    else:
        return Response("Error: Auth failed")
    return {"Authenticated_as": user.name}

アクティブユーザーを判別する関数

FastAPIが受け取ったリクエストのCookieからsession_idを取り出し、セッションデータベース内のエントリと一致すればログイン済みとみなします。 get_current_active_userでは、disabledのフラグが立っていないか判別し、get_admin_userでは、adminのフラグが立っているかどうか判別します。

auth/auth.py

async def get_current_user(ds: Session = Depends(get_db), cs: Session = Depends(get_cache), session_id: str = Depends(oauth2_scheme)):
    if not session_id:
        return None

    session = get_session_by_session_id(session_id, cs)
    if not session:
        return None

    username = session["name"]
    user_dict = get_user_by_name(username, ds)
    user=UserBase(**user_dict)

    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials",
        )
    return user

async def get_current_active_user(current_user: User = Depends(get_current_user)):
    if not current_user:
        raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="NotAuthenticated")
    if current_user.disabled:
        raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Inactive user")
    return current_user

async def get_admin_user(current_user: User = Depends(get_current_active_user)):
    print("CurrentUser: ", current_user)
    if not current_user.admin:
        raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Admin Privilege Required")
    return current_user

各種エンドポイントの保護

Depends(get_current_active_user)により、/api/user/エンドポイントはログインユーザーのみがアクセスできます。

auth/auth.py

@router.get("/user/")
async def get_user(user: UserBase = Depends(get_current_active_user)):
    return {"username": user.name, "email": user.email,}

customer/customer.pyで定義されたルートは認証済みユーザーのみ、admin/user.pyで定義されたルートはAdminユーザーのみがアクセスできます。

main.py

import admin.debug, admin.user, auth.auth, auth.debug
import customer.customer

app = FastAPI()

app.include_router(
    customer.customer.router,
    prefix="/api",
    tags=["CustomerForAuthenticatedUser"],
    dependencies=[Depends(auth.auth.get_current_active_user)],
)

app.include_router(
    admin.user.router,
    prefix="/api",
    tags=["AdminOnly"],
    dependencies=[Depends(auth.auth.get_admin_user)],
)

まとめ

SvelteとFastAPIを用いて構築したサンプルウェブサイトにGoogleのサインイン機能を実装しました。 GoogleからJWTを受け取った後、FastAPI側で新たにsession_idを発行し、Cookieを介してセッションを維持する方法で実装しました。 セッション情報はFastAPIのセッションデータベースで管理されており、いつでも管理者がセッションを無効にできます。 また、CookieにSecure属性とHttpOnly属性を付与することで、経路での盗聴防止やJavaScriptからのアクセス防止が可能になり、より安全なWebサイトの構築が可能です。

Comments