多字段登录通用解决方案

file

面临的问题:

  • 登录字段小于或等于2个的
  • 登录字段大于或等于2个的

登录字段不超过两个

我在网上看到一种相对简单解决方案,但是不能解决所有两个字段的验证:

filter_var($request->input('username'), FILTER_VALIDATE_EMAIL) ? 'email' : 'name'

过滤请求中的表单内容,实现区分 username。弊端显而易见,如果另一个不是 email 就抓瞎了……,下面是另一种通用的解决方案,在 LoginController 中重写 login 方法:

public function login(Requests $request) {
    //假设字段是 email
    if ($this->guard()->attempt(['email' =>$request->only('username'), 'password' => $request->only('password')]))) {
        return $this->sendLoginResponse($request);
    }

    //假设字段是 mobile
    if ($this->guard()->attempt(['mobile' =>$request->only('username'), 'password' => $request->only('password')])) {
        return $this->sendLoginResponse($request);
    }

    //假设字段是 username
    if ($this->guard()->attempt(['username' =>$request->only('username'), 'password' => $request->only('password')])) {
        return $this->sendLoginResponse($request);
    }

    return $this->sendFailedLoginResponse($request);
}

可以看到虽然能解决问题,但是显然有悖于 Laravel 的优雅风格!你也可以参照medz1楼回复的 方案。卖了这么多关子,下面跟大家分享一下我的解决方案。

登录字段大于或等于三个的(相对复杂一些)

file

为了方便理解我画了个大致的流程,只画了我认为重要的部分

  1. 首先需要自己实现一个 Illuminate\Contracts\Auth\UserProvider 的实现,具体可以参考 添加自定义用户提供器 但是我喜欢偷懒,就直接继承了 EloquentUserProvider,并重写了 retrieveByCredentials 方法:

    public function retrieveByCredentials(array $credentials)
    {
    if (empty($credentials)) {
        return;
    }
    
    $query = $this->createModel()->newQuery();
    
    foreach ($credentials as $key => $value) {
        if (! Str::contains($key, 'password')) {
            $query->orWhere($key, $value);
        }
    }
    
    return $query->first();
    }

    注意: 框架源生的是 $query->where($key, $value);,多个字段同时验证,也就是说字段之间是 and 的关系;将 $query->where($key, $value); 改为 $query->orWhere($key, $value);就可以了!

  2. 紧接着需要注册自定义的 UserProvider:

    class AuthServiceProvider extends ServiceProvider
    {
            /**
             * 注册任何应用认证/授权服务。
             *
             * @return void
             */
            public function boot()
            {
                    $this->registerPolicies();
    
                    Auth::provider('custom', function ($app, array $config) {
                            // 返回 Illuminate\Contracts\Auth\UserProvider 实例...
    
                            return new CustomUserProvider(new BcryptHasher(), config('auth.providers.custom.model'));
                    });
            }
    }
  3. 最后我们修改一下 auth.php 的配置就搞定了:

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'custom',
        ],
    
        'api' => [
            'driver' => 'passport',
            'provider' => 'users',
        ],
    ],
    
    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
    
         'custom' => [
             'driver' => 'custom',
             'model' => App\Models\User::class,
         ],
    ],
  4. 最后看一下 LoginController 的代码:

    public function login(LoginRequest $request)
    {
        $username = $request->get('username');
        $result = $this->guard()->attempt([
            'username' => $username,
            'email' => $username,
            'mobile' => $username,
            'password' => $request->get('password')]);

        if ($result) {
            return $this->sendLoginResponse($request);
        }

        $this->incrementLoginAttempts($request);
        return $this->sendFailedLoginResponse($request);
    }

现在哪怕你有在多个字段都妥妥的……??

最后得得承认一下,在与medz讨论的过程中有些上头了 ,在这里表示歉意。
后来我重新审视了一遍,觉得确实存在许多问题,于是绘制了认证流程图,还请多多指点??
感谢medz的认真评论和回复 :beers:


以下是 NicolaBonelli 给出的解决方案

我这里按照他的思路只实现了login,其他细节还需自己根据实际需求进行修改,更多讨论请看这里20楼

  1. 首先需要在已有的users表上移除用户凭证的字段,如email、name等等,然后再创建另一个用于保存用户凭证的Certificate模型和表,大致结构如下图所示:
    file
    users表定义如下:
    Schema::create('users', function (Blueprint $table) {
    $table->increments('id');
    //用户昵称
    $table->string('nickname')->nullable();
    $table->string('password');
    //用户状态
    $table->boolean('status')->default(true);
    $table->rememberToken();
    $table->timestamps();
    });

certificates表定义如下:

Schema::create('certificates', function (Blueprint $table) {
    $table->increments('id');
    //关联用户表
    $table->integer('user_id')->unsigned();
    //用户验证凭据
    $table->string('account')->unique();
    //凭据类型
    $table->string('type');
    $table->timestamps();
});
  1. 修改 config/auth.php 内容如下:
'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'certificate',
    ],
],

'providers' => [
    'certificate' => [
        'driver' => 'eloquent',
        'model' => App\Certificate::class,
    ],
],
  1. 紧接着修改Certificate模型,让他继承Illuminate\Foundation\Auth\User这个类,并重写 getAuthPassword方法:
/**
 * 定义模型关联
 * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 */
public function user()
{
    return $this->belongsTo(User::class, 'user_id');
}

/**
 * 获取用户凭证所对应的密码
 * @return string
 * @throws AuthenticationException
 */
public function getAuthPassword()
{
    if ($this->user()) {
        return $this->user->password;
    }
    throw new AuthenticationException();
}

到这里多字段登录功能算是实现了,但是注销时会因为certificates表中没有定义remember_token字段而导致抛出异常。要解决这个问题,我们还要重写logout方法,甚至重新实现一个自定义Guard……这里就不做分析了,有兴趣的可以自行Review Illuminate\Auth的源码!

最后感谢 NicolaBonelli 的分享 :beers:

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由 Summer 于 3年前 加精
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
讨论数量: 28
medz

file
我觉得多字段很简单,封装一个 username 函数,用于判断是何种类型,ThinkSNS Plus 支持 id、email、phone、name 四个字段,前端使用 login 字段作为用户名传递。
参考:https://github.com/slimkit/thinksns-plus/b...

并不需要这么复杂的逻辑。

username 函数代码:

file

3年前 评论
medz

我还有一个项目,简版的多字段登录案例:https://github.com/medz/phpwind/blob/maste...

3年前 评论
medz

一个输入框输入多种类型账号,后端使用 email,name等接收,无疑等于把判断做交给前端。虽然前端可以做。但是也有判断不准确的时候,随着代码的迭代,前后端分离。远没有直接一个字段提交过来后端判断来得直接。

3年前 评论

@medz
话说你认真的看了这篇文章吗?
你说用一个字段去判断,那加入某个人注册时候的用户名就是一个手机号呢?但是他的 mobile 字段是另一个手机号,当他用用户名的那个手机号登录是不是就会登录不了呢?
让前端去判断我觉得不太实际,要么不准确,如我上面所说,要么需要大量的前后端交互……

3年前 评论

@medz 老实说,你这种做法我考虑过,正常的思路确实没问题,但是逆向来看的话,就有 Bug……

3年前 评论

再者,如果还有更多的字段,而这些又跟username 字段类似,没有想 email 和 mobile 那样的特性,毫无规律可言,你怎么办?
比如员工的工号……

3年前 评论
medz

额~认真看过,有些地方你写的我确实没明白,感觉语序不对可能理解偏差了。不过大多数系统只需要 phone、email、username 登录。我这样的做法更简单。

3年前 评论
medz

其次,用户名是有规则的,严格地说,用户名不应该允许用户使用纯数字和手机号格式。

3年前 评论
medz

其实,你是参考你的图,你的图透露的信息是你要一个输入框输入可能是 ID,可能是用户名,可能是手机号,可能是邮箱,还要你后面说的可能是工号。一个输入框,可是你传递表单为不同字段。我自然这样理解了,你可能误会我的回复了。

3年前 评论

@medz 更新了一下,画了一个认证流程图 :beers:

3年前 评论

先点赞。但是感觉就是这么一个小功能,搞的有点复杂了,这一个文件,那一个的。。。

3年前 评论

我在想是否可以加2个字段,guard_type('email/phone/other') guard_value 根据用户注册的类型来选择

3年前 评论

难道重点不是登录界面很好看么。。

3年前 评论
hxd

你这个支持的字段超级多啊,一般有email 和 mobile 就够了,现在感觉大趋势是一个 mobile 搞定,

3年前 评论

@839891627 是的,简单点的 medz 已经给出了比较好的方案

3年前 评论

@我是谁 其实无论用户是用什么方式注册的,只要它补全了其他唯一标识符,那么使用其他字段都应该可以登录的。比如我微信扫码登录,平台让我绑定手机,然后我有绑定了邮箱,那么应可以用邮箱去登录的……主要还是看平台是否允许这么做 :smile:

3年前 评论

@BradStev 对的,一般 B2C 的服务都是这样的,能表示用户唯一性的信息也就那几个。

但是对于企业内部来说比如要实现工号和用户名都能登录,而工号的规律又不那么明显,那么久这个方法就排上用场了!
其实主要还是因为我不擅长 正则表达式 :see_no_evil:

3年前 评论
hxd

@Jinrenjie 了解了,确实是需要的这么多个字段来登录了

3年前 评论

多种登录方式, 最好的实现还是

  • users LARAVEL的 users 表
column type note
id int 用户 id
password varchar 口令
status int 状态
created_at ... ...
  • users_login 扩展一张用户名表
column type note
id int 绑定 id
user_id int 用户 id
type varchar phone?username?email?wechat_openid?..
create_at ... ...

这样, 哪怕你一个账号支持一千种登录都没问题, 而且在数据量大后做表结构优化也好做.

3年前 评论

上面回复的 users_login少了个 account 字段. 存储用户名或 openid 的

3年前 评论

@NicolaBonelli 我认真的分析了一下你的设计思路,确实可行!!!(这个方法挺棒的)从横向转为纵向,对于用户登录凭证的存储和可扩展性来说确实优于单一表格的横向多字段存储。

但是不推荐这么做,这样设计带来的最大问题就是与已有的扩展包不兼容(因为你的password凭证不在一个表里)例如 Passport 认证,也要自定义UserProvider,重写retrieveByCredentials 方法,当然如果你不打算用 Laravel 自带的那套机制就另当别论了……

为了给其他人提供更多的可选思路,我打算把你的方法添加到文章中,并注明来源,不知可否?

3年前 评论

@Jinrenjie laravel 的扩展包基本都是 trait. 覆盖掉关键即可方法
最后一个问题, 当然可以. 哈哈哈

3年前 评论

用了or 就不能触发索引了,小项目可以,大项目 就不好玩了

2年前 评论

@waynet 嗯,这倒是,当时水平有限,考虑不到数据库索引问题!不过大项目肯定会吧认证模块做成微服务,有专门的架构肯定不会这样去做……

2年前 评论
bing8u

在文件 app/Http/Controllers/Auth/LoginController.php 中重写 attemptLogin方法。

这才是妥妥的, 要几个字段就几个,还简单!

代码如下:

protected function attemptLogin(Request $request)
    {
        return collect(['name', 'email', 'mobile'])->contains(function ($value) use ($request) {
            $account = $request->get('account');
            $password = $request->get('password');
            return $this->guard()->attempt([$value => $account, 'password' => $password, 'locking' => 0], $request->filled('remember'));
        });
    }
2年前 评论

@bing8u 如果是api呢?也一样吗?

1年前 评论
bing8u

@Sher guard的attempt方法 只在中间件web中可用

1年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!