原理

用过Typecho的都知道,Typecho本身就有登录/注册功能,只不过其主要目的还是给博主自己用的,并不适合开放给读者,一方面,界面实在太丑、太简陋了,另一方面,登录完成后直接跳转到管理后台也很不合适。但是,我们可以依葫芦画瓢,实现我们自己的前台登录/注册功能。因此,在动手之前,先简单了解一下Typecho内部是如何实现的还是很有必要的。

以登录为例,登录功能一共涉及到两个核心文件,一个文件是admin/login.php,核心代码如下:

<form action="<?php $options->loginAction(); ?>" method="post" name="login" role="form">
    <p>
        <label for="name" class="sr-only"><?php _e('用户名或邮箱'); ?></label>
        <input type="text" id="name" name="name" value="<?php echo $rememberName; ?>" placeholder="<?php _e('用户名或邮箱'); ?>" class="text-l w-100" autofocus />
    </p>
    <p>
        <label for="password" class="sr-only"><?php _e('密码'); ?></label>
        <input type="password" id="password" name="password" class="text-l w-100" placeholder="<?php _e('密码'); ?>" required />
    </p>
    <p class="submit">
        <button type="submit" class="btn btn-l w-100 primary"><?php _e('登录'); ?></button>
        <input type="hidden" name="referer" value="<?php echo $request->filter('html')->get('referer'); ?>" />
    </p>
    <p>
        <label for="remember">
            <input<?php if (\Typecho\Cookie::get('__typecho_remember_remember')): ?> checked<?php endif; ?> type="checkbox" name="remember" class="checkbox" value="1" id="remember" /> <?php _e('下次自动登录'); ?>
        </label>
    </p>
</form>

这个文件主要存放的是和用户交互的表单界面,由于我们需要实现前台登录,所以这个文件肯定是不能再用了。我们只需要模仿着写一个自己的登录表单,然后将action设置为<?php $options->loginAction(); ?>(其本质就是一个类似于https://域名/index.php/action/login?_=xxxxxxxxxxx的请求地址)即可。

而另一个文件是var/Widget/Login.php,核心代码如下:

class Login extends Users implements ActionInterface
{
    public function action()
    {
        // protect
        $this->security->protect();

        /** 如果已经登录 */
        if ($this->user->hasLogin()) {
            /** 直接返回 */
            $this->response->redirect($this->options->index);
        }

        ... ...

        /** 跳转验证后地址 */
        if (!empty($this->request->referer)) {
            /** fix #952 & validate redirect url */
            if (
                0 === strpos($this->request->referer, $this->options->adminUrl)
                || 0 === strpos($this->request->referer, $this->options->siteUrl)
            ) {
                $this->response->redirect($this->request->referer);
            }
        } elseif (!$this->user->pass('contributor', true)) {
            /** 不允许普通用户直接跳转后台 */
            $this->response->redirect($this->options->profileUrl);
        }

        $this->response->redirect($this->options->adminUrl);
    }
}

这是一个实现了ActionInterface接口的Login类,里面只有一个action()方法,用于处理登录逻辑,处理完成后,根据不同的条件跳转到不同的页面,其中referer是在前面的表单中通过hidden标签指定的,你可以通过它来自定义跳转的地址,不过我们前端登录是希望通过JS异步请求接口,然后根据返回值来更新界面,并不希望后端重定向,因此我们需要把重定向改为返回值,这个referer也用不上。

这两个文件的基本作用我们了解了,但Typecho是如何将表单提交地址https://域名/index.php/action/login与Login类关联起来的呢?这就涉及到了另一个文件:var/Widget/Action.php,从中可以看到如下路由映射关系:

这样,二者就联系起来了。

具体实现

做过 主题 和 插件 开发的朋友通过上面的分析应该不难想到,如果不希望修改源代码,完全是可以通过插件调用Helper::addAction(...)和Helper::removeAction(...)来实现自己的登录处理逻辑,然后在主题中实现前端表单页面的。

但我没有这么做,原因前面提到过,源码我迟早是要改的,所以,这次也不想兜大圈子,直接改源码最省事,下面看看我的实现代码吧!还是以登录为例,首先是自定义表单,核心代码如下:

<form action="<?php $this->options->loginAction(); ?>" method="post" name="login" role="form">
    <div class="modal-header border-0">
        <h1 class="modal-title fs-5" id="loginModalLabel"><?php _e('登录') ?></h1>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
    </div>
    <div class="modal-body pb-0">
        <div class="mb-3">
            <input type="text" name="name" class="form-control" placeholder="<?php _e('用户名或邮箱'); ?>" autofocus />
        </div>
        <div class="mb-3">
            <input type="password" name="password" class="form-control" placeholder="<?php _e('密码'); ?>" required />
        </div>
        <div class="form-check">
            <input class="form-check-input" type="checkbox" name="remember" value="1" id="remember">
            <label class="form-check-label" for="remember">
                <?php _e('下次自动登录'); ?>
            </label>
        </div>
    </div>
    <div class="modal-footer border-0 justify-content-between">
        <div class="d-flex align-items-center">
            <button type="submit" class="btn btn-dark"><?php _e('登录'); ?></button>
            <a href="#" class="ms-2 link-secondary link-offset-2 link-underline-opacity-0 link-underline-opacity-100-hover" data-bs-toggle="modal" data-bs-target="#forgetModal"><?php _e('忘记密码?'); ?></a>
        </div>

        <?php if ($this->options->allowRegister): ?>
            <div class="d-flex align-items-center">
                <span><?php _e('没有账号?') ?></span>
                <a href="#" class="link-secondary link-offset-2 link-underline-opacity-0 link-underline-opacity-100-hover" data-bs-toggle="modal" data-bs-target="#registerModal"><?php _e('立即注册'); ?></a>
            </div>
        <?php endif; ?>
    </div>
</form>

这是一个基于Bootstrap 5实现的模态窗,效果如下图所示:

代码看似很多,但核心其实就是账号、密码,再加一个提交按钮,其它的都可以无视,而具体提交的JS代码如下:

const loginForm = document.querySelector("#loginModal form");
formSubmit(loginForm, "frontLogin");

function formSubmit(form, doMethod) {
  if (!form) {
    return;
  }

  form.addEventListener("submit", (e) => {
    e.preventDefault();
    const form = e.target;
    const formData = new FormData(form);
    const data = {};
    for (const [key, value] of formData.entries()) {
      data[key] = value;
    }

    data.do = doMethod;
    axios
      .post(form.action, data, {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
      })
      .then(function (response) {
        const data = response.data;
        showToast(data.message, data.success ? "success" : "error");
        if (data.success) {
          window.location.reload();
        }
      })
      .catch(function (error) {
        console.log(error);
      });
  });
}

这里有三点需要说明一下:

为了方便,这里用到了axios库,你也可以用其它的,如JQuery或原生的XMLHttpRequest、fetch等;
除了表单参数之外,还为请求指定了一个do参数,值为frontLogin,用于区分前台登录和后台登录;
为了简单起见,这里登录成功后直接调用了window.location.reload();方法重新加载当前页面,事实上,你也可以通过JS更新DOM节点,用户体验会更好一些。
前端提交后,请求会经过路由最终到达Login类的action()方法,这时我们需要通过如下代码来处理前台登录逻辑:

class Login extends Users implements ActionInterface
{
    /**
     * 初始化函数
     *
     * @access public
     * @return void
     */
    public function action()
    {
        $this->on($this->request->is('do=frontLogin'))->frontLogin();

        // protect
        $this->security->protect();
        ...
    }

    function frontLogin()
    {
        /** 如果已经登录 */
        if ($this->user->hasLogin()) {
            echo json_encode([
                'success' => true,
                'message' => _t('您已经登录了')
            ]);
            exit;
        }

        $expire = 30 * 24 * 3600;
        $name = $this->request->get('name');
        $password = $this->request->get('password');
        if (empty($name) || empty($password)) {
            echo json_encode([
                'success' => false,
                'message' => _t('账号或密码不能为空')
            ]);
            exit;
        }

        $valid = $this->user->login($name, $password, false, $this->request->is('remember=1') ? $expire : 0);
        if (!$valid) {
            echo json_encode([
                'success' => false,
                'message' => _t('账号或密码错误')
            ]);
            exit;
        }

        echo json_encode([
            'success' => true,
            'message' => _t('登录成功')
        ]);
        exit;
    }
}

这里也有几点需要说明的:

在action()的开始位置调用$this->on($this->request->is('do=frontLogin'))->frontLogin();拦截请求,这里的do=frontLogin就是我们表单提交时传入的参数,参数命中后,请求会交由frontLogin()方法处理;
frontLogin()需要输出一个JSON对象,而不是直接跳转,输出完成之后,需要执行exit中断请求,否则代码会继续执行。

结尾

通过上述几个步骤,我们自定义的前台登录功能就实现了。注册的思路也是一样的,原文件分别对应admin/register.php和var/Widget/Register.php两个文件,就不再赘述了。

原文地址:typecho如何实现前台登录/注册

文章目录