flask-login 原始碼解析

Wendell_Hu發表於2019-03-04

這篇文章介紹了 flask-login 是如何實現一個不需要使用資料庫的使用者認證元件的。

flask-login 的基本使用

在介紹 flask-login 的工作原理之前先來簡要回顧一下 flask-login 的使用方法。

首先要建立一個 LoginManager 的例項並註冊在 Flask 例項上,然後提供一個 user_loader 回撥函式來根據會話中儲存的使用者 ID 來載入使用者物件。

login_manager = LoginManager()
login_manager.init_app(app)

@login_manager.user_loader
def load_user(user_id):
    return User.get(user_id)
複製程式碼

flask-login 還要求你對資料物件做一些改動,新增以下屬性和方法:

@property
def is_active(self):
   return True

@property
def is_authenticated(self):
   return True

@property
def is_anonymous(self):
   return False

#: 這個方法返回一個能夠識別唯一使用者的 ID
def get_id(self):
   try:
       return text_type(self.id)
   except AttributeError:
       raise NotImplementedError(`No `id` attribute - override `get_id``)
複製程式碼

完成這些設定工作之後,就可以使用 flask-login 了,一些典型的用法包括登入和登出使用者:login_user(user)logout_user(),及使用 @login_required 保護一些檢視函式,檢測當前使用者是否有訪問的許可權(根據是否認證進行區別):

@app.route("/settings")
@login_required
def settings():
    pass
複製程式碼

以及通過 current_user 物件來訪問當前使用者。

flask-login 原始碼解析

我們按照使用過程中呼叫 flask-login 的順序來解析其原始碼。

LoginManager 物件

先來看 LoginManager 物件,它用於記錄所有的配置資訊,其 __init__ 方法中初始化了這些配置資訊。一個 LoginManager 物件通過 init_app 方法註冊到 Flask 例項上:

def init_app(self, app, add_context_processor=True):
   app.login_manager = self
   app.after_request(self._update_remember_cookie)

   self._login_disabled = app.config.get(`LOGIN_DISABLED`, False)

   if add_context_processor:
       app.context_processor(_user_context_processor)
複製程式碼

這個方法的主要工作是在 Flask 例項的 after_request 鉤子上新增了一個使用者更新 remember_me cookie 的函式,並在 Flask 的上下文處理器中新增了一個使用者上下文處理器。

def _user_context_processor():
    return dict(current_user=_get_user())
複製程式碼

這個上下文處理器設定了一個全域性可訪問的變數 current_user,這樣我們就可以在檢視函式或者模板檔案中訪問這個變數了。

user_loader 修飾器

然後就到了這個方法,它是 LoginManager 的例項方法,把 user_callback 設定成我們傳入的函式,在實際的使用過程中,我們是通過修飾器傳入這個函式的,就是 load_user(user_id) 函式。

def user_loader(self, callback):
   self.user_callback = callback
   return callback
複製程式碼

該方法要求你的回撥函式必須能夠接收一個 unicode 編碼的 ID 並返回一個使用者物件,如果使用者不存在就返回 None。

login_user 方法

我們跳過對 User 類的修改,直接來看這個方法。

def login_user(user, remember=False, force=False, fresh=True):
    if not force and not user.is_active:
        return False

    user_id = getattr(user, current_app.login_manager.id_attribute)()
    session[`user_id`] = user_id
    session[`_fresh`] = fresh
    session[`_id`] = current_app.login_manager._session_identifier_generator()

    if remember:
        session[`remember`] = `set`

    _request_ctx_stack.top.user = user
    user_logged_in.send(current_app._get_current_object(), user=_get_user())
    return True
複製程式碼

如果使用者不活躍 not.is_active 而且不要求強制登入 force,就返回失敗。否則,先得到 user_id,它是通過 getattr 函式訪問 userlogin_manager.id_attribute 屬性得到的。追根溯源,最終 getattr 訪問的是 userget_id 方法,這就是為什麼 flask-login 要求我們在 User 類中新增該方法。

然後在 Flask 提供的 session 中新增以下三個 session:user_id _fresh _id,其中 _id 是通過 LoginManager_session_identifier_generator 方法獲取到的,而這個方法預設繫結在這個方法上:

def _create_identifier():
    user_agent = request.headers.get(`User-Agent`)
    if user_agent is not None:
        user_agent = user_agent.encode(`utf-8`)
    base = `{0}|{1}`.format(_get_remote_addr(), user_agent)
    if str is bytes:
        base = text_type(base, `utf-8`, errors=`replace`)  # pragma: no cover
    h = sha512()
    h.update(base.encode(`utf8`))
    return h.hexdigest()
複製程式碼

不用太深究,知道這個方法最終根據放著使用者代理和 IP 資訊生成了一個加鹽的 ID 就行了,它的作用是防止有人偽造 cookie。

然後根據是否需要記住使用者新增 remember session。最後,在 _request_ctx_stack.top 中新增該使用者,發出一個使用者登入訊號後返回成功。在這個登入訊號中,呼叫了 _get_user 方法,_get_user 方法的細節是先檢測在 _request_ctx_stack.top 中有沒有使用者資訊,如果沒有,就通過 _load_user 方法在棧頂新增使用者資訊,如果有就返回這個使用者物件。_load_user 方法很重要,但是在這裡不會被呼叫,很明顯 _request_ctx_stack.top 中肯定有 user 值,我們待會再來看這個方法。

def _get_user():
    if has_request_context() and not hasattr(_request_ctx_stack.top, `user`):
        current_app.login_manager._load_user()

    return getattr(_request_ctx_stack.top, `user`, None)
複製程式碼

login_required 修飾器

這個修飾器常被用來保護只有登入使用者才能訪問的檢視函式,它會在實際呼叫檢視函式之前先檢查當前使用者是否已經登入並認證,如果沒有,就呼叫 LoginManager.unauthorized 這個回撥函式,它還對一些 HTTP 方法和測試情況提供了例外處理。

def login_required(func):
    @wraps(func)
    def decorated_view(*args, **kwargs):
        if request.method in EXEMPT_METHODS:
            return func(*args, **kwargs)
        elif current_app.login_manager._login_disabled:
            return func(*args, **kwargs)
        elif not current_user.is_authenticated:
            return current_app.login_manager.unauthorized()
        return func(*args, **kwargs)
    return decorated_view
複製程式碼

current_user 物件

在之前的分析中,可以看到這個變數經常出現並大有用途,開發者可以通過訪問這個變數來獲取到當前使用者,如果使用者未登入,獲取到的就是一個匿名使用者,它的定義:

current_user = LocalProxy(lambda: _get_user())
複製程式碼

_get_user() 方法之前已經講過,我們直接跳到 _load_user 方法。顯然,如果使用者登入後再次發出了請求,我們就要從 cookie,或者說,Flask 在此之上封裝的 session 中獲取使用者資訊才能正確地進行後續處理,_load_user 方法的作用就是這個,該方法如下:

def _load_user(self):
   user_accessed.send(current_app._get_current_object())

   config = current_app.config
   if config.get(`SESSION_PROTECTION`, self.session_protection):
       deleted = self._session_protection()
       if deleted:
           return self.reload_user()

   is_missing_user_id = `user_id` not in session
   if is_missing_user_id:
       cookie_name = config.get(`REMEMBER_COOKIE_NAME`, COOKIE_NAME)
       header_name = config.get(`AUTH_HEADER_NAME`, AUTH_HEADER_NAME)
       has_cookie = (cookie_name in request.cookies and
                     session.get(`remember`) != `clear`)
       if has_cookie:
           return self._load_from_cookie(request.cookies[cookie_name])
       elif self.request_callback:
           return self._load_from_request(request)
       elif header_name in request.headers:
           return self._load_from_header(request.headers[header_name])

   return self.reload_user()

def _session_protection(self):
   sess = session._get_current_object()
   ident = self._session_identifier_generator()

   app = current_app._get_current_object()
   mode = app.config.get(`SESSION_PROTECTION`, self.session_protection)

   if sess and ident != sess.get(`_id`, None):
       if mode == `basic` or sess.permanent:
           sess[`_fresh`] = False
           session_protected.send(app)
           return False
       elif mode == `strong`:
           for k in SESSION_KEYS:
               sess.pop(k, None)

           sess[`remember`] = `clear`
           session_protected.send(app)
           return True

   return False
複製程式碼

該方法首先保證 session 的安全,如果 session 通過了安全驗證,就通過 reload_user 方法過載使用者,否則檢查 session 中是否沒有 user_id 來過載使用者,如果沒有,通過三種不同的方式過載使用者。

def reload_user(self, user=None):
    ctx = _request_ctx_stack.top

    if user is None:
        user_id = session.get(`user_id`)
        if user_id is None:
            ctx.user = self.anonymous_user()
        else:
            if self.user_callback is None:
                raise Exception(
                    "No user_loader has been installed for this "
                    "LoginManager. Add one with the "
                    "`LoginManager.user_loader` decorator.")
            user = self.user_callback(user_id)
            if user is None:
                ctx.user = self.anonymous_user()
            else:
                ctx.user = user
    else:
        ctx.user = user
複製程式碼

在這個過載方法中,如果 user_id 不存在,就把匿名使用者載入到 _request_ctx_stack.top,否則根據 user_id 載入使用者,若該使用者不存在,仍載入匿使用者。

之後,current_user 就能獲取到使用者物件,或者是一個匿名使用者物件了。

current_user = LocalProxy(lambda: _get_user())
複製程式碼

logout_user 方法

這個方法先獲取當前使用者,然後移除 user_id _fresh 等 session,然後移除 remember,最後過載當前使用者,很明顯,過載之後會是一個匿名使用者。

def logout_user():
    user = _get_user()

    if `user_id` in session:
        session.pop(`user_id`)

    if `_fresh` in session:
        session.pop(`_fresh`)

    cookie_name = current_app.config.get(`REMEMBER_COOKIE_NAME`, COOKIE_NAME)
    if cookie_name in request.cookies:
        session[`remember`] = `clear`

    user_logged_out.send(current_app._get_current_object(), user=user)

    current_app.login_manager.reload_user()
    return True
複製程式碼

remember_me cookie

記得我們之前提到 flask-login 在 Flask 例項的 after_request 鉤子上新增了一個使用者更新 remember_me cookie 的函式嗎,我們顯然需要在請求的最後對 remember 進行處理。

def _update_remember_cookie(self, response):
   # Don`t modify the session unless there`s something to do.
   if `remember` in session:
       operation = session.pop(`remember`, None)
       if operation == `set` and `user_id` in session:
           self._set_cookie(response)
       elif operation == `clear`:
           self._clear_cookie(response)
    return response
複製程式碼

這個函式根據是否要設定 remember 來呼叫不同的函式

def _set_cookie(self, response):
    config = current_app.config
    cookie_name = config.get(`REMEMBER_COOKIE_NAME`, COOKIE_NAME)
    duration = config.get(`REMEMBER_COOKIE_DURATION`, COOKIE_DURATION)
    domain = config.get(`REMEMBER_COOKIE_DOMAIN`)
    path = config.get(`REMEMBER_COOKIE_PATH`, `/`)

    secure = config.get(`REMEMBER_COOKIE_SECURE`, COOKIE_SECURE)
    httponly = config.get(`REMEMBER_COOKIE_HTTPONLY`, COOKIE_HTTPONLY)

    data = encode_cookie(text_type(session[`user_id`]))

    try:
        expires = datetime.utcnow() + duration
    except TypeError:
        raise Exception(`REMEMBER_COOKIE_DURATION must be a ` +
                        `datetime.timedelta, instead got: {0}`.format(
                            duration))

    response.set_cookie(cookie_name,
                        value=data,
                        expires=expires,
                        domain=domain,
                        path=path,
                        secure=secure,
                        httponly=httponly)

def _clear_cookie(self, response):
    config = current_app.config
    cookie_name = config.get(`REMEMBER_COOKIE_NAME`, COOKIE_NAME)
    domain = config.get(`REMEMBER_COOKIE_DOMAIN`)
    path = config.get(`REMEMBER_COOKIE_PATH`, `/`)
    response.delete_cookie(cookie_name, domain=domain, path=path)
複製程式碼

總結

  • flask-login 使用 Flask 提供的 session 來儲存使用者資訊,通過 user_id 來記錄使用者身份,_id 來防止攻擊者對 session 的偽造。
  • 通過 _request_ctx_stack.top.user,flask-login 實現了執行緒安全。
  • 通過 cookie 來實現 remember 功能。

其他功能如 fresh login 請自行檢視原始碼瞭解。

仿造 flask-login 寫一個基於 token 的身份認證模組

flask-login 雖然好用,但由於其是基於 session 的,對於無狀態的 RESTful API 應用無能為力。我在一個最近的專案模仿了它的介面,實現了一個簡單但是好用的身份認證模組。

from functools import wraps
from flask import (_request_ctx_stack, has_request_context, request,
                   current_app)
from flask_restful import abort
from werkzeug.local import LocalProxy
from app.models.user import User

#: a proxy for the current user
#: it would be an anonymous user if no user is logged in
current_user = LocalProxy(lambda: _get_user())


class AnonymousUserMixin(object):
    @property
    def is_active(self):
        return False

    @property
    def is_authenticated(self):
        return False

    @property
    def is_anonymous(self):
        return True

    def __repr__(self):
        return `<AnonymousUser>`


class Manager(object):
    def __init__(self, app=None):
        if app:
            self.init_app(app)

    def init_app(self, app):
        app.login_manager = self
        app.context_processor(_user_context_processor)

        self._anonymous_user = AnonymousUserMixin
        self._login_disabled = app.config[`LOGIN_DISABLED`] or False

    @staticmethod
    def _load_user():
        """Try to load user from request.json.token and set it to
        `_request_ctx_stack.top.user`. If None, set current user as an anonymous
        user.
        """
        ctx = _request_ctx_stack.top
        json = request.json
        user = AnonymousUserMixin()

        if json and json.get(`token`):
            real_user = User.load_user_from_auth_token(json.get(`token`))
            if real_user:
                user = real_user

        ctx.user = user


def _get_user():
    """Get current user from request context."""
    if has_request_context() and not hasattr(_request_ctx_stack.top, `user`):
        current_app.login_manager._load_user()

    return getattr(_request_ctx_stack.top, `user`, None)


def _user_context_processor():
    """A context processor to prepare current user."""
    return dict(current_user=_get_user())


def login_user(user):
    """Login a user and return a token."""
    _request_ctx_stack.top.user = user
    return user.generate_auth_token()


def logout_user(user):
    """For a restful API there shouldn`t be a `logout` method because the
    server is stateless.
    """
    pass


def login_required(func):
    """Decorator to protect view functions that should only be accessed
    by authenticated users.
    """

    @wraps(func)
    def decorated_view(*args, **kwargs):
        if current_app.login_manager._login_disabled:
            return func(*args, **kwargs)
        elif not current_user.is_authenticated:
            abort(403, err=`40300`,
                  message=`Please login before carrying out this action.`)
        return func(*args, **kwargs)

    return decorated_view
複製程式碼

相關文章