Ctrl K
ComunidadGuías…

api/controllers/web/login.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
from http import HTTPStatus

from flask import make_response, request
from flask_restx import Resource
from jwt import InvalidTokenError
from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import Unauthorized

from controllers.common.fields import (
    AccessTokenData,
    AccessTokenResultResponse,
    LoginStatusResponse,
    SimpleResultDataResponse,
    SimpleResultResponse,
)
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console.auth.error import (
    AuthenticationFailedError,
    EmailCodeError,
    InvalidEmailError,
)
from controllers.console.error import AccountBannedError
from controllers.console.wraps import (
    decrypt_code_field,
    decrypt_password_field,
    model_validate,
)
from controllers.web import web_ns
from controllers.web.error import WebAppAccessServiceUnavailableError, WebAppNotFoundError
from controllers.web.flask_admission import web_anonymous_admission
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from libs.external_api import error_responses
from libs.helper import EmailStr
from libs.password import valid_password
from libs.token import (
    clear_webapp_access_token_from_cookie,
    extract_webapp_access_token,
    extract_webapp_passport,
)
from machinery.context import RequestContext
from services.entities.auth_entities import LoginPayloadBase
from services.web_authentication_service import (
    WebAccountBannedError,
    WebAuthenticationFailedError,
    WebInvalidCodeError,
    WebInvalidEmailError,
    WebInvalidTokenError,
)
from services.webapp_access_query_service import WebAppAccessAppNotFoundError, WebAppAccessUnavailableError

_ENTERPRISE_ONLY = frozenset({DeploymentEdition.ENTERPRISE})


class LoginPayload(LoginPayloadBase):
    @field_validator("password")
    @classmethod
    def validate_password(cls, value: str) -> str:
        return valid_password(value)


class EmailCodeLoginSendPayload(BaseModel):
    email: EmailStr
    language: str | None = None


class EmailCodeLoginVerifyPayload(BaseModel):
    email: EmailStr
    code: str
    token: str = Field(min_length=1)


class LoginStatusQuery(BaseModel):
    app_code: str | None = Field(default=None, description="Web app code")
    user_id: str | None = Field(default=None, description="End user session ID")


register_schema_models(web_ns, LoginPayload, EmailCodeLoginSendPayload, EmailCodeLoginVerifyPayload, LoginStatusQuery)
register_response_schema_models(
    web_ns,
    AccessTokenResultResponse,
    LoginStatusResponse,
    SimpleResultDataResponse,
    SimpleResultResponse,
)


@web_ns.route("/login")
class LoginApi(Resource):
    """Resource for web app email/password login."""

    @error_responses(AccountBannedError, AuthenticationFailedError)
    @web_ns.expect(web_ns.models[LoginPayload.__name__])
    @web_anonymous_admission(editions=_ENTERPRISE_ONLY)
    @web_ns.doc("web_app_login")
    @web_ns.doc(description="Authenticate user for web application access")
    @web_ns.doc(
        responses={
            200: "Authentication successful",
            400: "Bad request - invalid email or password format",
            401: "Authentication failed - email or password mismatch",
            404: "Account not found",
        }
    )
    @web_ns.response(200, "Authentication successful", web_ns.models[AccessTokenResultResponse.__name__])
    @decrypt_password_field
    @model_validate(LoginPayload)
    def post(self, payload: LoginPayload, request_context: RequestContext):
        """Authenticate user and login."""
        try:
            token = application_services().web_authentication.login_with_password(
                request_context,
                email=payload.email,
                password=payload.password,
            )
        except WebAccountBannedError as error:
            raise AccountBannedError() from error
        except WebAuthenticationFailedError as error:
            raise AuthenticationFailedError() from error

        # set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
        return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump(
            mode="json"
        )


# this api helps frontend to check whether user is authenticated
# TODO: remove in the future. frontend should redirect to login page by catching 401 status
@web_ns.route("/login/status")
class LoginStatusApi(Resource):
    @web_anonymous_admission()
    @web_ns.doc("web_app_login_status")
    @web_ns.doc(description="Check login status")
    @web_ns.doc(params=query_params_from_model(LoginStatusQuery))
    @web_ns.doc(
        responses={
            200: "Login status",
            401: "Login status",
            404: "App not found",
            503: "Web app access service unavailable",
            HTTPStatus.OK: "Login status",
            HTTPStatus.UNAUTHORIZED: "Login status",
        }
    )
    @web_ns.response(HTTPStatus.OK, "Login status", web_ns.models[LoginStatusResponse.__name__])
    @model_validate(LoginStatusQuery)
    def get(self, query: LoginStatusQuery, _request_context: RequestContext):
        try:
            status = application_services().web_authentication.get_login_status(
                app_code=query.app_code,
                user_id=query.user_id,
                access_token=extract_webapp_access_token(request),
                app_session_token=extract_webapp_passport(query.app_code, request) if query.app_code else None,
            )
        except WebAppAccessAppNotFoundError as error:
            raise WebAppNotFoundError() from error
        except WebAppAccessUnavailableError as error:
            raise WebAppAccessServiceUnavailableError() from error
        return LoginStatusResponse(
            logged_in=status.logged_in,
            app_logged_in=status.app_logged_in,
        ).model_dump(mode="json")


@web_ns.route("/logout")
class LogoutApi(Resource):
    @web_anonymous_admission()
    @web_ns.doc("web_app_logout")
    @web_ns.doc(description="Logout user from web application")
    @web_ns.doc(
        responses={
            200: "Logout successful",
        }
    )
    @web_ns.response(200, "Logout successful", web_ns.models[SimpleResultResponse.__name__])
    def post(self, _request_context: RequestContext):
        # response-contract:ignore hand-crafted response
        response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
        # enterprise SSO sets same site to None in https deployment
        # so we need to logout by calling api
        clear_webapp_access_token_from_cookie(response, samesite="None")
        return response


@web_ns.route("/email-code-login")
class EmailCodeLoginSendEmailApi(Resource):
    @error_responses(AuthenticationFailedError)
    @web_anonymous_admission(editions=_ENTERPRISE_ONLY)
    @web_ns.doc("send_email_code_login")
    @web_ns.doc(description="Send email verification code for login")
    @web_ns.expect(web_ns.models[EmailCodeLoginSendPayload.__name__])
    @web_ns.doc(
        responses={
            200: "Email code sent successfully",
            400: "Bad request - invalid email format",
            404: "Account not found",
        }
    )
    @web_ns.response(200, "Email code sent successfully", web_ns.models[SimpleResultDataResponse.__name__])
    @model_validate(EmailCodeLoginSendPayload)
    def post(self, payload: EmailCodeLoginSendPayload, _request_context: RequestContext):
        try:
            token = application_services().web_authentication.send_email_login_code(
                email=payload.email,
                language=payload.language,
            )
        except WebAccountBannedError as error:
            raise Unauthorized("Account is banned.") from error
        except WebAuthenticationFailedError as error:
            raise AuthenticationFailedError() from error
        return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")


@web_ns.route("/email-code-login/validity")
class EmailCodeLoginApi(Resource):
    @error_responses(AccountBannedError, AuthenticationFailedError, EmailCodeError, InvalidEmailError)
    @web_anonymous_admission(editions=_ENTERPRISE_ONLY)
    @web_ns.doc("verify_email_code_login")
    @web_ns.doc(description="Verify email code and complete login")
    @web_ns.expect(web_ns.models[EmailCodeLoginVerifyPayload.__name__])
    @web_ns.doc(
        responses={
            200: "Email code verified and login successful",
            400: "Bad request - invalid code or token",
            401: "Invalid token or expired code",
            404: "Account not found",
        }
    )
    @web_ns.response(
        200,
        "Email code verified and login successful",
        web_ns.models[AccessTokenResultResponse.__name__],
    )
    @decrypt_code_field
    @model_validate(EmailCodeLoginVerifyPayload)
    def post(self, payload: EmailCodeLoginVerifyPayload, request_context: RequestContext):
        try:
            token = application_services().web_authentication.login_with_email_code(
                request_context,
                email=payload.email,
                code=payload.code,
                token=payload.token,
            )
        except WebInvalidTokenError as error:
            raise InvalidTokenError() from error
        except WebInvalidEmailError as error:
            raise InvalidEmailError() from error
        except WebInvalidCodeError as error:
            raise EmailCodeError() from error
        except WebAccountBannedError as error:
            raise AccountBannedError() from error
        except WebAuthenticationFailedError as error:
            raise AuthenticationFailedError() from error

        # set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
        return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump(
            mode="json"
        )