Chapter 05

ログインページ

alivelimb
alivelimb
2022.05.25に更新

本章からは各ページの作成をしていきます。

まずはログインページです。完成イメージは以下の通りです。

ログイン画面gif

作成手順は以下の流れで行います。

No 分類 内容
1 Model BaseDataModel を継承したデータクラス User, Session を作成する
2 MockDB ユーザテーブルを作成し、管理者・会員ユーザを追加する
3 Service AuthAPIClient, UserAPIClient を作成する
4 Page PageId を定義し、BasePage を継承したページクラスを作る
5 Application MultiPageApp のpagesにページ ID とページクラスのペアを追加する

Model: データクラス User, Session を作成する

データクラス User, Session を作成します。User はユーザ ID などのユーザ情報を保持します。Session はログイン情報を保持します。

User

まずは データクラス User を定義します。ユーザ情報として、ユーザ ID, 名前, 生年月日, メールアドレス, ユーザ種別を保持します。ユーザ種別はページの閲覧権限の切り替えに使用し、管理者と会員を用意します。ユーザ種別を Enum 型で定義しておきましょう。

class UserRole(Enum):
    ADMIN = auto()
    MEMBER = auto()

User は以下のようになります。

@dataclass(frozen=True)
class User(BaseDataModel):
    user_id: str
    name: str
    birthday: date
    email: str
    role: UserRole

    def to_dict(self) -> dict[str, str]:
        return dict(
            user_id=self.user_id,
            name=self.name,
            birthday=self.birthday.isoformat(),
            email=self.email,
            role=self.role.name,
        )

    @classmethod
    def from_dict(cls, data: dict[str, str]) -> User:
        birthday = datetime.fromisoformat(data["birthday"])
        return User(
            user_id=data["user_id"],
            name=data["name"],
            birthday=birthday.date(),
            email=data["email"],
            role=UserRole[data["role"]],
        )

Session

次に データクラス Session を定義します。セッション情報としてセッション ID, ログイン中のユーザ ID, カート状態を保持します。セッション ID はセッションが作られる度にユニークな ID が発行されます。

@dataclass(frozen=True)
class Session(BaseDataModel):
    user_id: str
    cart: Cart
    session_id: str = str(uuid4())

    def to_dict(self) -> dict[str, str]:
        cart_dict = self.cart.to_dict()
        return dict(
            session_id=self.session_id,
            user_id=self.user_id,
            cart=json.dumps(cart_dict),
        )

    @classmethod
    def from_dict(cls, data: dict[str, str]) -> Session:
        cart_dict = json.loads(data["cart"])
        return Session(
            session_id=data["session_id"],
            user_id=data["user_id"],
            cart=Cart.from_dict(cart_dict),
        )

※なおコード中の Cart についてはカートページの実装時に紹介します。

MockDB: ユーザテーブルを作成し、管理者・会員ユーザを追加する

次にユーザテーブルを作成します。既に紹介した通り、dataset ではテーブルを明示的に作成する必要がなく、テーブルにデータを追加すると自動的にテーブルが作成されます。ユーザ種別が管理者・会員であるユーザを作成しておきましょう。

class MockDB:
    def __init__(self, dbpath: Path) -> None:
        s_dbpath = str(dbpath)
        self._dbname = f"sqlite:///{s_dbpath}"
        self._init_mock_db()

    # (中略)

    def _init_mock_db(self) -> None:
        self._create_mock_user_table()

    def _create_mock_user_table(self) -> None:
        mock_users = [
            User(
                user_id="member",
                name="会員",
                birthday=date(2000, 1, 1),
                email="guest@example.com",
                role=UserRole.MEMBER,
            ),
            User(
                user_id="admin",
                name="管理者",
                birthday=date(2000, 1, 1),
                email="admin@example.com",
                role=UserRole.ADMIN,
            ),
        ]
        with self.connect() as db:
            table: dataset.Table = db["users"]
            for mock_user in mock_users:
                table.insert(mock_user.to_dict())

MockDB をインスタンス化すると管理者・会員ユーザが追加されるようになりました。他のテーブルに事前にデータを入れたい場合は、_init_mock_dbメソッドに追加すれば OK です。

Service: AuthAPIClient, UserAPIClient を作成する

次はバックエンドと HTTP 通信する責務を持つ、Service を実装していましょう。ログインページでは AuthAPI にアクセスする AuthAPIClient と UserAPI にアクセスする UserAPIClient を作成します。

AuthAPIClient

まず AuthAPIClient の IF(インターフェース)を定義します。

class IAuthAPIClientService(Protocol):
    def login(self, user_id: str, password: str) -> str:
        pass

AuthAPIClientService は「ユーザ ID とパスワードでログインをリクエストし、セッション ID を取得する」login メソッドを持ちます。なお、今回はログインのみでログアウトは実装しません。

今回はバックエンドではなく MockDB, MockSessionDB にアクセスする MockAuthAPIClientService を実装します。

class MockAuthAPIClientService(IAuthAPIClientService):
    def __init__(self, mockdb: MockDB, session_db: MockSessionDB) -> None:
        self.mockdb = mockdb
        self.session_db = session_db

    # ログインに成功した場合、MockSessionDBにセッションを追加し、セッションIDを返す
    # ログインに失敗した場合、AuthenticationErrorを発生させる
    def login(self, user_id: str, password: str) -> str:
        if not self._verify_user(user_id, password):
            raise AuthenticationError

        session = Session(user_id=user_id, cart=Cart(user_id))
        with self.session_db.connect() as db:
            db.insert(session.to_dict())

        return session.session_id

    # 指定されたユーザIDを持つユーザがMockDBのユーザテーブルに入ればTrueを返す
    # ※パスワードの検証は行わない
    def _verify_user(self, user_id: str, password: str) -> bool:
        with self.mockdb.connect() as db:
            table: dataset.Table = db["users"]
            user_data = table.find_one(user_id=user_id)

        return user_data is not None

コード中のコメントの通りですが、あくまでモックなので認証はユーザ ID のみで行います。詳細な認証ロジックに関してはバックエンド編で紹介できればと思っています。

UserAPIClient

次に UserAPIClinet の IF(インターフェース)を定義します。

class IUserAPIClientService(Protocol):
    def get_by_user_id(self, user_id: str) -> User:
        pass

    def get_by_session_id(self, session_id: str) -> User:
        pass

UserAPIClientService は以下の 2 つのメソッドを持つことを定義しました。

No メソッド名 機能
1 get_by_user_id ユーザ ID を指定してユーザのデータクラスを取得する
2 get_by_session_id セッション ID を指定してログインユーザのデータクラスを取得する

今回はバックエンドではなく MockDB, MockSessionDB にアクセスする MockUserAPIClientService を実装します。

class MockUserAPIClientService(IUserAPIClientService):
    def __init__(self, mockdb: MockDB, session_db: MockSessionDB) -> None:
        self.mockdb = mockdb
        self.session_db = session_db

    def get_by_user_id(self, user_id: str) -> User:
        with self.mockdb.connect() as db:
            table: dataset.Table = db["users"]
            user_data = table.find_one(user_id=user_id)

        if user_data is None:
            raise NotFoundError(user_id)

        return User.from_dict(user_data)

    def get_by_session_id(self, session_id: str) -> User:
        user_id = self._get_user_id(session_id)
        user = self.get_by_user_id(user_id)
        return user

    # セッションIDを用いてログインユーザのユーザIDを取得する
    def _get_user_id(self, session_id: str) -> str:
        with self.session_db.connect() as db:
            query = Query()
            doc = db.search(query.session_id == session_id)

        return doc[0]["user_id"]

Page: PageId を定義し、ページクラスを作る

ようやく Streamlit を用いてページを作っていきましょう。再掲になりますが、ページを実装する手順は以下の通りです。

  1. PageId の属性を増やす
  2. BasePage を継承したページクラスを作る
  3. MultiPageApp のpagesにページ ID とページクラスのペアを追加する

PageId の定義

まずはログインページの PageId を定義します。ログイン状態に関係なくアクセス出来るページにはPUBLIC_をプレフィックスとしてつけておくことにします。

class PageId(Enum):
    PUBLIC_LOGIN = auto() # 追加

ページクラスの作成

次にページクラスを作っていきます。

class LoginPage(BasePage):
    def render(self) -> None:
        auth_api_client: IAuthAPIClientService = self.ssm.get_auth_api_client() # (A)
        user_api_client: IUserAPIClientService = self.ssm.get_user_api_client() # (A)

        # ページ描画
        st.title(self.title)
        with st.form("form"):
            user_id = st.text_input("UserID")
            password = st.text_input("Password", type="password")
            submit_button = st.form_submit_button(label="ログイン")

        if submit_button:
            try:
                session_id = auth_api_client.login(user_id, password)
                user = user_api_client.get_by_session_id(session_id)
            except AuthenticationError:
                st.sidebar.error("ユーザID または パスワードが間違っています。") # (C)
                return

            # ログインに成功した場合、成功メッセージを表示する
            st.sidebar.success("ログインに成功しました。") # (B)
            self.ssm.set_user(user) # (A)
            self.ssm.set_session_id(session_id) # (A)

コード中(A)では StreamlitSessionManager(SSM)とデータのやり取りを行なっています。LoginPage は BasePage を継承しているため、インスタンス化する時に SSM を渡すことになります。また、先ほど実装した AuthAPIClientService, UserAPIClientService は SSM で保持されることになります。

コード中(B)ではログイン成功メッセージをサイドバーで表示しています。メインページ部分で表示すると、メッセージボックスの高さの分だけ、表示が全体的に下にずれてしまいます。

コード中(C)については本章の「セキュリティ」で後述します。

Application: ページ ID とページクラスのペアを追加する

最後に作成したページを Application に追加します。

def init_pages(ssm: StreamlitSessionManager) -> list[BasePage]:
    pages = [
        LoginPage(page_id=PageId.PUBLIC_LOGIN.name, title="ログイン", ssm=ssm) # 追加
    ]
    return pages

これでログインページが Application に登録されました。次に SSM で管理するオブジェクトを追加しておきます。

def init_session() -> StreamlitSessionManager:
    # (中略)
    ssm = StreamlitSessionManager(
        auth_api_client=MockAuthAPIClientService(mockdb, session_db), # 追加
        user_api_client=MockUserAPIClientService(mockdb, session_db), # 追加
    )
    return ssm

セッションキーの登録もしておきましょう。

class SessionKey(Enum):
    AUTH_API_CLIENT = auto() # 追加
    USER_API_CLIENT = auto() # 追加
    USER = auto() # 追加
    SESSION_ID = auto() # 追加
    USERBOX = auto() # 追加

また、SSM のメソッドとして登録したオブジェクトの Getter/Setter を登録する必要がありますが、ここでの紹介は省略するため、ソースコードを確認して下さい。

セキュリティ

実装については以上になりますが、最後にセキュリティの観点で補足しておきます。

認証・認可

情報処理安全確保支援士などでセキュリティの勉強をすると「認証」と「認可」の違いについて紹介されます。これらの違いはざっくりと以下の通りです。

内容
認証 対象が誰であるかを確認する
認可 対象にどのような権限を与えるか判断する

英語だと認証は Authentication、認可は Authorization というので、それぞれ AuthN, AuthZ と読んだりもします。具体例としては認証だと生体認証、認可だとコンサート等のチケットなどが挙げられます。また顔パスのように認証・認可を 1 つの媒体で行なっているものもあるので、混同しやすいところではあります。

YaEC ではログインで認証を行い、認可は各ページ・API で行なっています。

認証認可のより詳細な説明は、クラスメソッドさんのよくわかる認証と認可がおすすめです。

認証失敗時のメッセージ

先ほど紹介したコード中(C)でログインが失敗した際に「ユーザ ID または パスワードが間違っています。」と表示しています。「メッセージ内容でユーザ ID かパスワードのどちらが間違っているか明記した方が良いのでは?」と思う方もいるかもしれません。しかし、これはセキュリティの観点で明記しない方が良いです。

仮に「パスワードが間違っています」という表記をした場合、攻撃者に「入力したユーザ ID のユーザは存在する」という情報を与えてしまいます。そのため、ユーザ ID もしくはパスワードが間違っていたとしても、どちらが間違っているかまでは明記しないべきです。

Web アプリケーションのセキュリティ全般については徳丸さんの体系的に学ぶ 安全な Web アプリケーションの作り方がおすすめです。