基于 Laravel + Swoole + Vue 搭建实时在线聊天室(七):基于 Muse UI 3.0 的前端用户认证功能实现

引入 Material Design 字体和图标文件

由于我们的聊天室项目前端界面是基于 Muse UI 的,而 Muse UI 基于 Material Design 实现,所以开始之前,需要在视图入口文件 resources/views/index.blade.php<head></head> 标签之间新增如下字体和图文件引入:

这是 Muse UI 推荐的字体和图标库,引入之后,刷新首页,就可以看到如下图标被渲染出来:

引入Material Design 字体和图标

注:Muse UI 是基于 Vue 2.0 开发的、优雅的 Material Design UI 组件库,可以类比为 Bootstrap、iView、Element 之类的 UI 框架。

调整登录及注册界面代码适配 Muse UI 3.0

点击“我的”链接,提示需要登录:

用户登录提示

点击“去登录”链接,进入登录页面完成用户认证:

用户登录界面

乍看起来似乎没有用户名和密码输入框,其实是背景图颜色覆盖的原因:

用户登录界面

不过这个样式和 Vue-Webchat 的渲染效果有些出入,这是因为我们现在安装的是 3.0 版本的 Muse UI,而 Vue-Webchat 使用的是 Muse UI 2.0 版本,因此需要到 resources/js/pages/Login.vue 模板中对 Muse UI 组件代码做一些调整:

<div class="content">
  <form action="" name="form2">
    <mu-text-field v-model="username" label="账号" label-float icon="account_circle" full-width></mu-text-field>
    <br/>
    <mu-text-field v-model="password" type="password" label="密码" label-float icon="locked" full-width></mu-text-field>
    <br/>
    <div class="btn-radius" @click="submit">登录</div>
  </form>
  <div @click="register" class="tip-user">注册帐号</div>
</div>

...

export default {
  data() {
    return {
        loading: "",
        username: "",
        password: ""
    };
  },
  methods: {
    async submit() {
      const name = this.username.trim();
      const password = this.password.trim();
      ...

重新运行 npm run dev 编译前端资源,再强制刷新登录页面,就可以看到如下渲染效果了,还算 OK:

正常的用户登录界面

顺带把注册组件 resources/js/pages/Register.vue 模板代码也修改下,逻辑和 Login.vue 一样:

<div class="content">
  <form action="" name="form1">
    <mu-text-field v-model="username" label="账号" label-float icon="account_circle" full-width></mu-text-field>
    <br/>
    <mu-text-field v-model="password" type="password" label="密码" label-float icon="locked" full-width></mu-text-field>
    <br/>
    <div class="btn-radius" @click="submit">注册</div>
  </form>
  <div @click="login" class="tip-user">
    我已有帐号
  </div>
</div>

...

export default {
  data() {
      return {
          username: "",
          password: ""
      };
  },
  methods: {
    async submit() {
      const name = this.username.trim();
      const password = this.password.trim();
      ...

然后运行 npm run dev 重新编译前端资源。

前后端注册接口联调

前端注册表单提交逻辑

接下来,我们从用户注册开始,联调前后端用户认证相关接口,打开用户注册界面 http://webchats.test/#/register

用户注册界面

通过阅读前端注册表单提交逻辑源码,可以看到最终负责与后端注册接口交互的逻辑位于 resources/js/api/server.js 中:

// 注册接口
RegisterUser: data => Axios.post('/register', data),

参照我们之前后端 API 认证功能实现教程中的路由定义,需要将这里的后端接口调整为 /api/register,由于我们在 resources/js/api/axios.js 中已经定义了 baseURL,所以这里不用做任何调整:

const baseURL = '/api';

...

// 请求统一处理
instance.interceptors.request.use(async config => {
    if (config.url && config.url.charAt(0) === '/') {
        config.url = `${baseURL}${config.url}`;
    }

    return config;
}, error => Promise.reject(error));

除此之外,前端传递给后端的注册表单数据还包含默认头像 URL(对应逻辑位于 Register.vue 中):

const data = {
    name: name,
    password: password,
    src: src
};
const res = await this.$store.dispatch("registerSubmit", data);

该字段可以映射到后端 users 表新增的 avatar 字段,为了简化前端逻辑,我们可以假定用户输入的账户都是邮箱,然后后端截取邮箱前缀作为用户名。

后端认证接口调整

对应的,在 app/Http/Controllers/AuthController.php 中修改后端用户注册方法 register 实现如下:

public function register(Request $request)
{
    // 验证注册字段
    $validator = Validator::make($request->all(), [
        'name' => 'bail|required|email|max:100|unique:users',
        'password' => 'bail|required|string|min:6',
        'src' => 'bail|active_url|max:255'
    ]);
    if ($validator->fails()) {
        return [
            'errno' => 1,
            'data' => $validator->errors()->first()
        ];
    }

    // 在数据库中创建用户并返回
    $email = $request->input('name');
    try {
        $user = User::create([
            'name' => substr($email, 0, strpos($email, '@')),
            'email' => $email,
            'avatar' => $request->input('src'),
            'password' => Hash::make($request->input('password')),
            'api_token' => Str::random(60)
        ]);
        if ($user) {
            return [
                'errno' => 0,
                'data' => $user
            ];
        } else {
            return [
                'errno' => 1,
                'data' => '保存用户到数据库失败'
            ];
        }
    } catch (QueryException $exception) {
        return [
            'errno' => 1,
            'data' => '保存用户到数据库异常:' . $exception->getMessage()
        ];
    }
}

我们修改了表单验证规则和返回数据格式以便适配前端代码。此外,不要忘了修改 App\User 模型类的 $fillable 属性,新增 avatar 字段:

protected $fillable = [
    'name', 'email', 'password', 'api_token', 'avatar'
];

重新启动 Swoole HTTP 服务器,让后端接口调整生效:

bin/laravels reload

测试用户注册功能

回到前端注册页面,输入如下用户信息,点击“注册”按钮,会弹出错误信息:

注册功能测试

注册功能测试

这里我们仅作简单演示,不会覆盖所有测试用例,接下来,我们按照正常逻辑填写注册用户信息:

注册功能测试

注册成功,跳转到首页。

此时我们点击首页下方“我的”链接,可以进入我的页面:

用户个人中心界面

修改用户界面代码适配 Muse UI 3.0

Vue-Webchat 这个演示项目在用户认证这块的处理比较简单,用户认证成功后会将用户信息保存到 localStorage,只要 localStorage 中包含 userid 即认为用户已经认证,userid 即用户账号,对应的保存逻辑是用户注册或登录后完成的:

const res = await this.$store.dispatch("registerSubmit", data);
if (res.status === "success") {
    Toast({
        content: res.data.data,
        timeout: 1000,
        background: "#2196f3"
    });
    this.$store.commit("setUserInfo", {
        type: "userid",
        value: data.name
    });
    this.$store.commit("setUserInfo", {
        type: "src",
        value: data.src
    });
    this.getSvgModal.$root.$options.clear();
    this.$store.commit("setSvgModal", null);
    this.$router.push({ path: "/" });
    socket.emit("login", { name });
} else {
    await Alert({
        content: res.data.data
    });
}

但是目前在这个“我的”页面报错,提示 <mu-raised-button> 这个组件注册失败,这也是 Muse UI 3.0 版本的缘故,新版本已经将这个组件合并到 <mu-button> 中,所以我们需要打开用户界面所在文件 resources/js/pages/Home.vue,修改对应的组件模板代码如下:

<div class="logout">
  <mu-button @click="logout" class="demo-raised-button" full-width>退出</mu-button>
</div>

另外,Muse UI 3.0 对 <mu-list> 的使用也做了调整,所以需要将对应的组件引用代码调整如下:

<mu-list>
    <mu-list-item button @click="changeAvatar">
      <mu-list-item-action>
        <mu-icon slot="left" value="send"/>
      </mu-list-item-action>
      <mu-list-item-title>修改头像</mu-list-item-title>
    </mu-list-item>
    <mu-list-item button @click="handleTips">
      <mu-list-item-action>
        <mu-icon slot="left" value="inbox"/>
      </mu-list-item-action>
      <mu-list-item-title>赞助一下</mu-list-item-title>
    </mu-list-item>
    <mu-list-item button @click="handleGithub">
      <mu-list-item-action>
        <mu-icon slot="left" value="grade"/>
      </mu-list-item-action>
      <mu-list-item-title>Github地址</mu-list-item-title>
    </mu-list-item>
    <mu-list-item button @click="rmLocalData">
      <mu-list-item-action>
        <mu-icon slot="left" value="drafts"/>
      </mu-list-item-action>
      <mu-list-item-title>清除缓存</mu-list-item-title>
    </mu-list-item>
  </mu-list>

然后运行 npm run dev 重新编译前端资源,再刷新“我的”页面:

用户个人中心页面

既可以看到正常渲染的用户中心界面了,列表项也支持点开了:

用户个人中心页面

我们在这个页面点击“退出”按钮退出登录状态,然后再次点击页面底部“我的”链接进入登录页面,测试登录接口的调用。

前后端登录接口联调

后端登录接口调整

和注册功能一样,前端登录界面和功能不需要调整,相应逻辑和注册也是一样的,最终通过 /api/login 接口调用后端用户登录逻辑。后端登录接口需要做调整来适配前端登录表单提交,在 app/Http/Controllers/AuthController.php 中,修改 login 方法如下:

public function login(Request $request)
{
    // 验证登录字段
    $validator = Validator::make($request->all(), [
        'name' => 'required|email|string',
        'password' => 'required|string',
    ]);
    if ($validator->fails()) {
        return [
            'errno' => 1,
            'data' => $validator->errors()->first()
        ];
    }

    $email = $request->input('name');
    $password = $request->input('password');
    $user = User::where('email', $email)->first();
    // 用户校验成功则返回 Token 信息
    if ($user && Hash::check($password, $user->password)) {
        $user->api_token = Str::random(60);
        $user->save();
        return [
            'errno' => 0,
            'data' => $user
        ];
    }

    return [
        'errno' => 1,
        'data' => '用户名和密码不匹配,请重新输入'
    ];
}

重启 Swoole HTTP 服务器,让后端修改生效:

bin/laravels reload

测试用户登录功能

然后在前端登录页面填写用户信息,点击“登录”按钮:

用户登录界面

登录成功后同样跳转到首页。这一次,我们点击页面底部“我的”链接,却显示并没有认证成功,这是因为调用后端登录接口成功后,是从返回数据中拿的账号信息,后端接口字段和前端默认接口字段并不匹配,后端用户账号字段是 res.data.data.email,而前端访问的是一个不存在的字段 res.data.name,此外,头像字段也不匹配:

const res = await this.$store.dispatch("loginSubmit", data);
if (res.status === "success") {
    Toast({
        content: res.data.data,
        timeout: 1000,
        background: "#2196f3"
    });
    this.$store.commit("setUserInfo", {
        type: "userid",
        value: res.data.name
    });
    this.$store.commit("setUserInfo", {
        type: "src",
        value: res.data.src
    });
    this.getSvgModal.$root.$options.clear();
    this.$store.commit("setSvgModal", null);
    this.$router.push({ path: "/" });
    socket.emit("login", { name });
} else {
    Alert({
        content: res.data.data
    });
}

调整前端用户登录成功后保存用户信息逻辑

借着这个机会,我们在用户注册和登录后端接口调用成功后统一从后端获取数据保存到前端 localStorage,同时保存 api_token 字段到前端以备后用。

首先在 resources/js/pages/Login.vue 中,修改后端登录接口调用成功后保存用户信息到 localStorage 的相关代码:

const res = await this.$store.dispatch("loginSubmit", data);
if (res.status === "success") {
    Toast({
        content: res.data.message,
        timeout: 1000,
        background: "#2196f3"
    });
    this.$store.commit("setUserInfo", {
        type: "userid",
        value: res.data.user.email
    });
    this.$store.commit("setUserInfo", {
        type: "token",
        value: res.data.user.api_token
    });
    this.$store.commit("setUserInfo", {
        type: "src",
        value: res.data.user.avatar
    });
    this.getSvgModal.$root.$options.clear();
    this.$store.commit("setSvgModal", null);
    this.$router.push({ path: "/" });
    socket.emit("login", { name });
} else {
    Alert({
        content: res.data.message
    });
}

调整后端用户认证相关接口返回响应字段

与此同时,后端接口也将用户数据和消息数据区分开:

public function register(Request $request)
{
    // 验证注册字段
    $validator = Validator::make($request->all(), [
        'name' => 'bail|required|email|max:100|unique:users',
        'password' => 'bail|required|string|min:6',
        'src' => 'bail|active_url|max:255'
    ]);
    if ($validator->fails()) {
        return [
            'errno' => 1,
            'message' => $validator->errors()->first()
        ];
    }

    // 在数据库中创建用户并返回
    $email = $request->input('name');
    try {
        $user = User::create([
            'name' => substr($email, 0, strpos($email, '@')),
            'email' => $email,
            'avatar' => $request->input('src'),
            'password' => Hash::make($request->input('password')),
            'api_token' => Str::random(60)
        ]);
        if ($user) {
            return [
                'errno' => 0,
                'user' => $user,
                'message' => '用户注册成功'
            ];
        } else {
            return [
                'errno' => 1,
                'message' => '保存用户到数据库失败'
            ];
        }
    } catch (QueryException $exception) {
        return [
            'errno' => 1,
            'message' => '保存用户到数据库异常:' . $exception->getMessage()
        ];
    }
}

public function login(Request $request)
{
    // 验证登录字段
    $validator = Validator::make($request->all(), [
        'name' => 'required|email|string',
        'password' => 'required|string',
    ]);
    if ($validator->fails()) {
        return [
            'errno' => 1,
            'message' => $validator->errors()->first()
        ];
    }

    $email = $request->input('name');
    $password = $request->input('password');
    $user = User::where('email', $email)->first();
    // 用户校验成功则返回 Token 信息
    if ($user && Hash::check($password, $user->password)) {
        $user->api_token = Str::random(60);
        $user->save();
        return [
            'errno' => 0,
            'user' => $user,
            'message' => '用户登录成功'
        ];
    }

    return [
        'errno' => 1,
        'message' => '用户名和密码不匹配,请重新输入'
    ];
}

public function logout(Request $request)
{
    $user = Auth::guard('auth:api')->user();
    if (!$user) {
        return [
            'errno' => 1,
            'message' => '用户已退出'
        ];
    }
    $userModel = User::find($user->id);
    $userModel->api_token = null;
    $userModel->save();
    return [
        'errno' => 0,
        'message' => '用户退出成功'
    ];
}

修改前端用户注册成功后保存用户信息逻辑

修改 resources/js/pages/Register.vue 相关 localStorage 代码如下:

const res = await this.$store.dispatch("registerSubmit", data);
if (res.status === "success") {
    Toast({
        content: res.data.message,
        timeout: 1000,
        background: "#2196f3"
    });
    this.$store.commit("setUserInfo", {
        type: "userid",
        value: res.data.user.email
    });
    this.$store.commit("setUserInfo", {
        type: "token",
        value: res.data.user.api_token
    });
    this.$store.commit("setUserInfo", {
        type: "src",
        value: res.data.user.avatar
    });
    this.getSvgModal.$root.$options.clear();
    this.$store.commit("setSvgModal", null);
    this.$router.push({ path: "/" });
    socket.emit("login", { name });
} else {
      await Alert({
        content: res.data.message
      });
}

修改前端用户退出清除用户信息逻辑

与 WebSocket 相关的用户认证逻辑我们放到下一篇教程去讨论。最后在 resources/js/pages/Home.vue 中用户退出时清除 token 数据:

async logout() {
    const data = await Confirm({
        title: "提示",
        content: "你忍心离开吗?"
    });
    if (data === "submit") {
        clear();
        this.$store.commit("setUserInfo", {
            type: "userid",
            value: ""
        });
        this.$store.commit("setUserInfo", {
            type: "token",
            value: ""
        });
        this.$store.commit("setUserInfo", {
            type: "src",
            value: ""
        });
        this.$store.commit("setUnread", {
            room1: 0,
            room2: 0
        });
        this.$router.push("/");
        this.$store.commit("setTab", false);
    }
},

重新编译前端资源:

npm run dev

重新启动 Swoole HTTP 服务器:

bin/laravels reload

再次测试用户登录功能

再次访问登录页面,登录成功后,页面上方会提示登录成功,进入“我的”页面,也可以看到已经登录成功,并且在 localStorage 中可以看到新增的 token 字段:

用户个人中心页面

至此,前后端联动的用户认证功能已经完成了,接下来,我们要正式开始进入基于 WebSocket 实现实时聊天室的代码开发了。

注:本项目代码已提交到 Github:https://github.com/nonfu/webchat

上一篇: 基于 Laravel + Swoole + Vue 搭建实时在线聊天室(六):建立 socket.io 客户端与 Swoole Websocket 服务器的连接

下一篇: 基于 Laravel + Swoole + Vue 搭建实时在线聊天室(八):Websocket 服务端重构与用户认证