-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathchapter9.html
More file actions
1548 lines (1308 loc) · 141 KB
/
chapter9.html
File metadata and controls
1548 lines (1308 loc) · 141 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>Ruby on Rails 教程 - 第 9 章 高级登录功能</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="description" content="最好的 Ruby on Rails 入门教程"/>
<meta name="keywords" content="ruby, rails, tutorial"/>
<meta name="author" content="Michael Hartl"/>
<meta name="translator" content="安道"/>
<meta name="generator" content="persie 0.0.5.1"/>
<link rel="stylesheet" type="text/css" href="//railstutorial-china.org/assets/css/main.css"/>
<link rel="stylesheet" type="text/css" href="book.css"/>
<script type="text/javascript" src="//railstutorial-china.org/assets/js/global.js"></script>
</head>
<body class="book-page">
<nav class="navbar">
<div class="container">
<div class="clearfix">
<a class="navbar-brand hidden-sm-up" href="//railstutorial-china.org/" title="Ruby on Rails 教程">Ruby on Rails 教程</a>
<button class="navbar-toggler hidden-sm-up pull-xs-right" type="button" data-toggle="collapse" data-target="#main-nav">☰</button>
</div>
<a class="navbar-brand hidden-xs-down" href="//railstutorial-china.org/" title="Ruby on Rails 教程">Ruby on Rails 教程</a>
<div class="collapse navbar-toggleable-xs pull-sm-right" id="main-nav">
<ul class="nav navbar-nav">
<li class="nav-item"><a class="nav-link" href="//railstutorial-china.org/" title="首页">首页</a></li>
<li class="nav-item"><a class="nav-link" href="//railstutorial-china.org/blog/" title="博客">博客</a></li>
<li class="nav-item active"><a class="nav-link" href="//railstutorial-china.org/book/" title="阅读">阅读</a></li>
<li class="nav-item"><a class="nav-link" href="//railstutorial-china.org/#ebook" title="电子书">电子书</a></li>
</ul>
</div>
</div>
</nav>
<div class="content">
<div class="container">
<div class="row">
<div class="col-lg-offset-2 col-lg-8">
<div class="book-versions">
选择版本:
<a class="btn btn-primary" href="//railstutorial-china.org/book/" title="Ruby on Rails 教程(原书第 4 版,针对 Rails 5)">Rails 5</a>
<a class="btn btn-secondary" href="//railstutorial-china.org/rails42/" title="Ruby on Rails 教程(原书第 3 版,针对 Rails 4.2)">Rails 4.2</a>
<a class="btn btn-secondary" href="//railstutorial-china.org/rails4/" title="Ruby on Rails 教程(原书第 3 版,针对 Rails 4.0)">Rails 4.0</a>
<a class="btn btn-secondary" href="//railstutorial-china.org/rails3/" title="Ruby on Rails 教程(原书第 2 版,针对 Rails 3.2)">Rails 3.2</a>
</div>
<div class="alert alert-warning">
<p>在线版的内容可能落后于电子书,如果想及时获得更新,请<a href="//railstutorial-china.org/#ebook" title="购买电子书">购买电子书</a>。</p>
</div>
<article class="article">
<section data-type="chapter" id="advanced-login">
<h1><span class="title-label">第 9 章</span> 高级登录功能</h1>
<p><a class="xref-link" href="chapter8.html#basic-login">第 8 章</a>实现的登录系统完全可用,不过大多数现代的网站都能记住用户的登录状态,当用户关闭浏览器后再次访问网站时,仍是登录状态。本章使用持久 cookie 实现这种行为。首先,我们将实现自动记住用户登录状态的功能(<a class="xref-link" href="#remember-me">9.1 节</a>),这是 Bitbucket 和 GitHub 等网站采用的策略。随后,我们将提供一个“记住我”复选框,让用户自己选择是否记住登录状态,这是 Twitter 和 Facebook 等网站采用的策略。</p>
<p><a class="xref-link" href="chapter8.html#basic-login">第 8 章</a>已经为这个演示应用实现了完整的登录系统,如果愿意,你可以跳过本章,从<a class="xref-link" href="chapter10.html#updating-showing-and-deleting-users">第 10 章</a>开始阅读(一直到<a class="xref-link" href="chapter13.html#user-microposts">第 13 章</a>)。不过学习如何实现“记住我”功能也有好处,能为账户激活(<a class="xref-link" href="chapter11.html#account-activation">第 11 章</a>)和密码重设(<a class="xref-link" href="chapter12.html#password-reset">第 12 章</a>)奠定坚实的基础。而且,本章也能让你体验计算机的神奇,你曾在网上无数次见到这种“记住我”登录表单,现在终于有机会自己动手实现了。</p>
<section data-type="sect1" id="remember-me">
<h1><span class="title-label">9.1</span> 记住我</h1>
<p>本节添加一个功能,让应用记住用户的登录状态,即使关闭浏览器之后再访问,仍能记住用户的登录状态。这个“记住我”功能自动生效,除非用户退出,否则会一直处于登录状态。我们的实现方式还便于添加一个“记住我”复选框(<a class="xref-link" href="#remember-me-checkbox">9.2 节</a>)。</p>
<p>与往常一样,我建议在继续之前切换到一个主题分支:</p>
<div data-type="listing">
<div class="highlight language-sh"><pre><code><span class="nv">$ </span>git checkout <span class="nt">-b</span> advanced-login
</code></pre></div>
</div>
<section data-type="sect2" id="remember-token-and-digest">
<h2><span class="title-label">9.1.1</span> 记忆令牌和摘要</h2>
<p><a class="xref-link" href="chapter8.html#logging-in">8.2 节</a>使用 Rails 提供的 <code>session</code> 方法存储用户的 ID,但是浏览器关闭后这个信息就不见了。本节,我们将迈出实现持久会话的第一步:生成使用 <code>cookies</code> 方法创建持久 cookie 所需的记忆令牌(remember token),以及验证令牌所需的安全记忆摘要(remember digest)。</p>
<p><a class="xref-link" href="chapter8.html#the-log-in-method">8.2.1 节</a>说过,使用 <code>session</code> 方法存储的信息默认情况下就是安全的,但使用 <code>cookies</code> 方法存储的信息则不然。具体而言,持久 cookie 有被<a href="http://en.wikipedia.org/wiki/Session_hijacking" class="external-link">会话劫持</a>的风险,攻击者可以使用盗取的记忆令牌以某个用户的身份登录。盗取 cookie 中的信息主要有四种途径:(1)使用<a href="https://en.wikipedia.org/wiki/Packet_analyzer" class="external-link">包嗅探</a>工具截获不安全网络中传输的 cookie;<sup>[<a id="fn-ref-1" href="#fn-1">1</a>]</sup>(2)获取包含记忆令牌的数据库;(3)使用跨站脚本(Cross-Site Scripting,简称 XSS)攻击;(4)获取已登录用户的设备访问权。我们在 <a class="xref-link" href="chapter7.html#professional-grade-deployment">7.5 节</a>启用了全站 SSL,这样能避免别人嗅探网络中传输的数据,因此解决了第一个问题。为了解决第二个问题,我们不会存储记忆令牌本身,而是存储令牌的哈希摘要——这种方法和 <a class="xref-link" href="chapter6.html#adding-a-secure-password">6.3 节</a>一样,不存储原始密码,而是存储密码摘要。<sup>[<a id="fn-ref-2" href="#fn-2">2</a>]</sup>Rails 会转义插入视图模板中的内容,所以自动解决了第三个问题。对于最后一个问题,虽然没有万无一失的方法能避免攻击者获取已登录用户电脑的访问权,不过我们可以在每次用户退出后修改令牌,并且签名加密存储在浏览器中的敏感信息,尽量降低第四个问题导致的不良影响。</p>
<p>经过上述分析,我们计划按照下面的方式实现持久会话:</p>
<ol class="arabic">
<li>
<p>生成随机字符串,用作记忆令牌;</p>
</li>
<li>
<p>把这个令牌存入浏览器的 cookie 中,并把过期时间设为未来的某个日期;</p>
</li>
<li>
<p>在数据库中存储令牌的摘要;</p>
</li>
<li>
<p>在浏览器的 cookie 中存储加密后的用户 ID;</p>
</li>
<li>
<p>如果 cookie 中有用户的 ID,就用这个 ID 在数据库中查找用户,并且检查 cookie 中的记忆令牌和数据库中的哈希摘要是否匹配。</p>
</li>
</ol>
<p>注意,最后一步和登入用户很相似:使用电子邮件地址检索用户,然后(使用 <code>authenticate</code> 方法)验证提交的密码和密码摘要是否匹配(<a class="xref-link" href="chapter8.html#listing-find-authenticate-user">代码清单 8.7</a>)。可见,我们的实现方式和 <code>has_secure_password</code> 差不多。</p>
<p>首先,我们把所需的 <code>remember_digest</code> 属性加入 <code>User</code> 模型,如<a class="xref-link" href="#fig-user-model-remember-digest">图 9.1</a> 所示。</p>
<div id="fig-user-model-remember-digest" class="figure"><img src="images/chapter9/user_model_remember_digest.png" alt="user model remember digest" /><div class="figcaption"><span class="title-label">图 9.1</span>:添加 <code>remember_digest</code> 属性后的 <code>User</code> 模型</div></div>
<p>为了把<a class="xref-link" href="#fig-user-model-remember-digest">图 9.1</a> 中的数据模型添加到应用中,我们要生成一个迁移:</p>
<div data-type="listing">
<div class="highlight language-sh"><pre><code><span class="nv">$ </span>rails generate migration add_remember_digest_to_users remember_digest:string
</code></pre></div>
</div>
<p>(可以和 <a class="xref-link" href="chapter6.html#a-hashed-password">6.3.1 节</a>添加密码摘要的迁移比较一下。)与之前的迁移一样,迁移的名称以 <code>_to_users</code> 结尾,这么做是为了告诉 Rails 这个迁移是用来修改 <code>users</code> 表的。因为我们还指定了属性(<code>remember_digest</code>)及其类型(<code>string</code>),所以 Rails 会自动为我们生成迁移代码,如<a class="xref-link" href="#listing-add-remember-digest-to-users-generated">代码清单 9.1</a> 所示。</p>
<div id="listing-add-remember-digest-to-users-generated" data-type="listing">
<h5><span class="title-label">代码清单 9.1</span>:生成的迁移,用于添加记忆摘要</h5>
<div class="source-file">db/migrate/[timestamp]_add_remember_digest_to_users.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">AddRememberDigestToUsers</span> <span class="o"><</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">5.0</span><span class="p">]</span>
<span class="k">def</span> <span class="nf">change</span>
<span class="n">add_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:remember_digest</span><span class="p">,</span> <span class="ss">:string</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>我们不会通过记忆摘要检索用户,所以没必要在 <code>remember_digest</code> 列上添加索引,因此可以直接使用上述自动生成的迁移:</p>
<div data-type="listing">
<div class="highlight language-sh"><pre><code><span class="nv">$ </span>rails db:migrate
</code></pre></div>
</div>
<p class="pagebreak-before">现在我们要决定使用什么做记忆令牌。不同的方式基本上都差不多,其实只要是一定长度的随机字符串都行。Ruby 标准库中 <code>SecureRandom</code> 模块的 <code>urlsafe_base64</code> 方法刚好能满足我们的需求。<sup>[<a id="fn-ref-3" href="#fn-3">3</a>]</sup>这个方法返回长度为 22 的随机字符串,包含字符 A-Z、a-z、0-9、“-”和“_”(每一位都有 64 种可能,因此方法名中有“<a href="http://en.wikipedia.org/wiki/Base64" class="external-link">base64</a>”)。典型的 base64 字符串如下所示:</p>
<div data-type="listing">
<div class="highlight language-irb"><pre><code><span class="go">$ rails console</span>
<span class="go">>> SecureRandom.urlsafe_base64</span>
<span class="p">=></span> <span class="s2">"q5lt38hQDc_959PVoo6b7A"</span>
</code></pre></div>
</div>
<p>就像两个用户可以使用相同的密码一样,<sup>[<a id="fn-ref-4" href="#fn-4">4</a>]</sup>记忆令牌也没必要是唯一的,不过如果唯一的话,安全性更高。<sup>[<a id="fn-ref-5" href="#fn-5">5</a>]</sup>对 base64 字符串来说,22 个字符中的每一个都有 64 种取值可能,所以两个记忆令牌“碰撞”的几率小到可以忽略,只有 1/64<sup>22</sup> = 2<sup>-132</sup> ≈ 10<sup>-40</sup>。<sup>[<a id="fn-ref-6" href="#fn-6">6</a>]</sup>而且,使用可在 URL 中安全使用的 base64 字符串(如 <code>urlsafe_base64</code> 方法的名称所示),我们还能在账户激活和密码重设链接中使用类似的令牌。</p>
<p>记住用户的登录状态要创建一个记忆令牌,并且在数据库中存储这个令牌的摘要。我们已经定义了 <code>digest</code> 方法,并且在测试固件中用过(<a class="xref-link" href="chapter8.html#listing-digest-method">代码清单 8.21</a>)。基于上述分析,现在我们可以定义一个 <code>new_token</code> 方法,用于创建新令牌。和 <code>digest</code> 方法一样,新建令牌的方法也不需要用户对象,所以也定义为类方法,<sup>[<a id="fn-ref-7" href="#fn-7">7</a>]</sup>如<a class="xref-link" href="#listing-token-method">代码清单 9.2</a> 所示。</p>
<div id="listing-token-method" data-type="listing">
<h5><span class="title-label">代码清单 9.2</span>:添加生成令牌的方法</h5>
<div class="source-file">app/models/user.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">User</span> <span class="o"><</span> <span class="no">ApplicationRecord</span>
<span class="n">before_save</span> <span class="p">{</span> <span class="nb">self</span><span class="p">.</span><span class="nf">email</span> <span class="o">=</span> <span class="n">email</span><span class="p">.</span><span class="nf">downcase</span> <span class="p">}</span>
<span class="n">validates</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">length: </span><span class="p">{</span> <span class="ss">maximum: </span><span class="mi">50</span> <span class="p">}</span>
<span class="no">VALID_EMAIL_REGEX</span> <span class="o">=</span> <span class="sr">/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i</span>
<span class="n">validates</span> <span class="ss">:email</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">length: </span><span class="p">{</span> <span class="ss">maximum: </span><span class="mi">255</span> <span class="p">},</span>
<span class="ss">format: </span><span class="p">{</span> <span class="ss">with: </span><span class="no">VALID_EMAIL_REGEX</span> <span class="p">},</span>
<span class="ss">uniqueness: </span><span class="p">{</span> <span class="ss">case_sensitive: </span><span class="kp">false</span> <span class="p">}</span>
<span class="n">has_secure_password</span>
<span class="n">validates</span> <span class="ss">:password</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">length: </span><span class="p">{</span> <span class="ss">minimum: </span><span class="mi">6</span> <span class="p">}</span>
<span class="c1"># 返回指定字符串的哈希摘要</span>
<span class="k">def</span> <span class="nc">User</span><span class="o">.</span><span class="nf">digest</span><span class="p">(</span><span class="n">string</span><span class="p">)</span>
<span class="n">cost</span> <span class="o">=</span> <span class="no">ActiveModel</span><span class="o">::</span><span class="no">SecurePassword</span><span class="p">.</span><span class="nf">min_cost</span> <span class="p">?</span> <span class="no">BCrypt</span><span class="o">::</span><span class="no">Engine</span><span class="o">::</span><span class="no">MIN_COST</span> <span class="p">:</span>
<span class="no">BCrypt</span><span class="o">::</span><span class="no">Engine</span><span class="p">.</span><span class="nf">cost</span>
<span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="n">string</span><span class="p">,</span> <span class="ss">cost: </span><span class="n">cost</span><span class="p">)</span>
<span class="k">end</span>
<span class="c1"># 返回一个随机令牌</span>
<span class="k">def</span> <span class="nc">User</span><span class="o">.</span><span class="nf">new_token</span>
<span class="hll"> <span class="no">SecureRandom</span><span class="p">.</span><span class="nf">urlsafe_base64</span></span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>我们计划定义 <code>user.remember</code> 方法把记忆令牌和用户关联起来,并且把相应的记忆摘要存入数据库。<a class="xref-link" href="#listing-add-remember-digest-to-users-generated">代码清单 9.1</a> 中的迁移已经添加了 <code>remember_digest</code> 属性,但是还没有 <code>remember_token</code> 属性。我们要找到一种方法,通过 <code>user.remember_token</code> 获取令牌(为了存入 cookie),但又不在数据库中存储令牌。<a class="xref-link" href="chapter6.html#adding-a-secure-password">6.3 节</a>解决过类似的问题——使用虚拟属性 <code>password</code> 和数据库中的 <code>password_digest</code> 属性。其中,虚拟属性 <code>password</code> 由 <code>has_secure_password</code> 方法自动创建。但是,我们要自己编写代码创建 <code>remember_token</code> 属性,方法是使用 <a class="xref-link" href="chapter4.html#a-user-class">4.4.5 节</a>用过的 <code>attr_accessor</code>,创建一个可访问的属性:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">User</span> <span class="o"><</span> <span class="no">ApplicationRecord</span>
<span class="hll"> <span class="nb">attr_accessor</span> <span class="ss">:remember_token</span></span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="p">.</span>
<span class="nf">def</span> <span class="n">remember</span>
<span class="hll"> <span class="nb">self</span><span class="p">.</span><span class="nf">remember_token</span> <span class="o">=</span> <span class="o">...</span></span>
<span class="hll"> <span class="n">update_attribute</span><span class="p">(</span><span class="ss">:remember_digest</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span></span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>注意 <code>remember</code> 方法中第一行代码的赋值操作。根据 Ruby 处理对象内部赋值操作的规则,如果没有 <code>self</code>,创建的是一个名为 <code>remember_token</code> 的局部变量——这并不是我们想要的行为。使用 <code>self</code> 的目的是确保把值赋给用户的 <code>remember_token</code> 属性。(现在你应该知道为什么 <code>before_save</code> 回调中要使用 <code>self.email</code>,而不是 <code>email</code> 了吧(<a class="xref-link" href="chapter6.html#listing-email-downcase">代码清单 6.32</a>)。)<code>remember</code> 方法的第二行代码使用 <code>update_attribute</code> 方法更新记忆摘要。(<a class="xref-link" href="chapter6.html#updating-user-objects">6.1.5 节</a>说过,这个方法会跳过验证。这里必须跳过验证,因为我们无法获取用户的密码和密码确认。)</p>
<p>基于上述分析,创建有效令牌和摘要的方法是:首先使用 <code>User.new_token</code> 创建一个新记忆令牌,然后使用 <code>User.digest</code> 生成摘要,最后更新数据库中的记忆摘要。实现这个步骤的 <code>remember</code> 方法如<a class="xref-link" href="#listing-user-model-remember">代码清单 9.3</a> 所示。</p>
<div id="listing-user-model-remember" data-type="listing">
<h5><span class="title-label">代码清单 9.3</span>:在 <code>User</code> 模型中添加 <code>remember</code> 方法 <span class="green">GREEN</span></h5>
<div class="source-file">app/models/user.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">User</span> <span class="o"><</span> <span class="no">ApplicationRecord</span>
<span class="hll"> <span class="nb">attr_accessor</span> <span class="ss">:remember_token</span></span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="o">.</span>
<span class="c1"># 为了持久保存会话,在数据库中记住用户</span>
<span class="k">def</span> <span class="nf">remember</span>
<span class="hll"> <span class="nb">self</span><span class="p">.</span><span class="nf">remember_token</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">new_token</span></span>
<span class="hll"> <span class="n">update_attribute</span><span class="p">(</span><span class="ss">:remember_digest</span><span class="p">,</span> <span class="no">User</span><span class="p">.</span><span class="nf">digest</span><span class="p">(</span><span class="n">remember_token</span><span class="p">))</span></span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<h5 id="exercises-remember-token" class="discrete">练习</h5>
<ol class="arabic">
<li>
<p>打开控制台,把数据库中的第一个用户赋值给 <code>user</code> 变量,然后直接调用 <code>remember</code> 方法,确认它可用。<code>remember_token</code> 的值与 <code>remember_digest</code> 的值有何区别?</p>
</li>
<li>
<p>在<a class="xref-link" href="#listing-user-model-remember">代码清单 9.3</a> 中,我们定义了生成令牌和摘要的类方法,前面都加上了 <code>User</code>。这么定义没问题,而且因为我们会使用 <code>User.new_token</code> 和 <code>User.digest</code>,或许这样定义意思更明确。不过,定义类方法有两种更常用的方式,一种有点让人困惑,一种极其让人困惑。运行测试组件,确认<a class="xref-link" href="#listing-token-digest-self">代码清单 9.4</a>(有点让人困惑)和<a class="xref-link" href="#listing-token-digest-class-self">代码清单 9.5</a>(极其让人困惑)中的实现方式是正确的。(注意,在<a class="xref-link" href="#listing-token-digest-self">代码清单 9.4</a> 和<a class="xref-link" href="#listing-token-digest-class-self">代码清单 9.5</a> 中,<code>self</code> 是 <code>User</code> 类,而 <code>User</code> 模型中的其他 <code>self</code> 都是用户对象实例。这就是让人困惑的根源所在。)</p>
</li>
</ol>
<div id="listing-token-digest-self" data-type="listing">
<h5><span class="title-label">代码清单 9.4</span>:使用 <code>self</code> 定义生成令牌和摘要的方法 <span class="green">GREEN</span></h5>
<div class="source-file">app/models/user.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">User</span> <span class="o"><</span> <span class="no">ApplicationRecord</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="o">.</span>
<span class="c1"># 返回指定字符串的哈希摘要</span>
<span class="hll"> <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">digest</span><span class="p">(</span><span class="n">string</span><span class="p">)</span></span>
<span class="n">cost</span> <span class="o">=</span> <span class="no">ActiveModel</span><span class="o">::</span><span class="no">SecurePassword</span><span class="p">.</span><span class="nf">min_cost</span> <span class="p">?</span> <span class="no">BCrypt</span><span class="o">::</span><span class="no">Engine</span><span class="o">::</span><span class="no">MIN_COST</span> <span class="p">:</span>
<span class="no">BCrypt</span><span class="o">::</span><span class="no">Engine</span><span class="p">.</span><span class="nf">cost</span>
<span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="n">string</span><span class="p">,</span> <span class="ss">cost: </span><span class="n">cost</span><span class="p">)</span>
<span class="k">end</span>
<span class="c1"># 返回一个随机令牌</span>
<span class="hll"> <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">new_token</span></span>
<span class="no">SecureRandom</span><span class="p">.</span><span class="nf">urlsafe_base64</span>
<span class="k">end</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="p">.</span>
<span class="nf">end</span>
</code></pre></div>
</div>
<div id="listing-token-digest-class-self" data-type="listing">
<h5><span class="title-label">代码清单 9.5</span>:使用 <code>class << self</code> 定义生成令牌和摘要的方法 <span class="green">GREEN</span></h5>
<div class="source-file">app/models/user.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">User</span> <span class="o"><</span> <span class="no">ApplicationRecord</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="p">.</span>
<span class="hll"> <span class="nf">class</span> <span class="o"><<</span> <span class="nb">self</span></span>
<span class="c1"># 返回指定字符串的哈希摘要</span>
<span class="hll"> <span class="k">def</span> <span class="nf">digest</span><span class="p">(</span><span class="n">string</span><span class="p">)</span></span>
<span class="n">cost</span> <span class="o">=</span> <span class="no">ActiveModel</span><span class="o">::</span><span class="no">SecurePassword</span><span class="p">.</span><span class="nf">min_cost</span> <span class="p">?</span> <span class="no">BCrypt</span><span class="o">::</span><span class="no">Engine</span><span class="o">::</span><span class="no">MIN_COST</span> <span class="p">:</span>
<span class="no">BCrypt</span><span class="o">::</span><span class="no">Engine</span><span class="p">.</span><span class="nf">cost</span>
<span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="n">string</span><span class="p">,</span> <span class="ss">cost: </span><span class="n">cost</span><span class="p">)</span>
<span class="k">end</span>
<span class="c1"># 返回一个随机令牌</span>
<span class="hll"> <span class="k">def</span> <span class="nf">new_token</span></span>
<span class="no">SecureRandom</span><span class="p">.</span><span class="nf">urlsafe_base64</span>
<span class="k">end</span>
<span class="k">end</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="o">.</span>
</code></pre></div>
</div>
</section>
<section data-type="sect2" id="login-with-remembering">
<h2><span class="title-label">9.1.2</span> 登录时记住登录状态</h2>
<p>定义好 <code>user.remember</code> 方法之后,我们可以创建持久会话了,方法是,把(加密后的)用户 ID 和记忆令牌作为持久 cookie 存入浏览器。为此,我们要使用 <code>cookies</code> 方法。这个方法和 <code>session</code> 一样,可以视为一个散列。一个 cookie 有两部分信息,一个是 <code>value</code>(值),一个是可选的 <code>expires</code>(过期日期)。例如,我们可以创建一个值为记忆令牌,20 年后过期的 cookie,实现持久会话:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="n">cookies</span><span class="p">[</span><span class="ss">:remember_token</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">value: </span><span class="n">remember_token</span><span class="p">,</span>
<span class="ss">expires: </span><span class="mi">20</span><span class="p">.</span><span class="nf">years</span><span class="p">.</span><span class="nf">from_now</span><span class="p">.</span><span class="nf">utc</span> <span class="p">}</span>
</code></pre></div>
</div>
<p>(这里使用了一个便利的 Rails 时间辅助方法,参见<a class="xref-link" href="#aside-time-helpers">旁注 9.1</a>。 )Rails 应用经常使用 20 年后过期的 cookie,所以 Rails 提供了一个特殊的方法 <code>permanent</code>,用于创建这种 cookie,所以上述代码可以简写为:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="n">cookies</span><span class="p">.</span><span class="nf">permanent</span><span class="p">[</span><span class="ss">:remember_token</span><span class="p">]</span> <span class="o">=</span> <span class="n">remember_token</span>
</code></pre></div>
</div>
<p>这样写,Rails 会自动把过期时间设为 <code>20.years.from_now</code>。</p>
<div data-type="sidebar" id="aside-time-helpers" class="sidebar">
<h5><span class="title-label">旁注 9.1</span>:cookie 在 <code>20.years.from_now</code> 之后过期</h5>
<p>你可能还记得,<a class="xref-link" href="chapter4.html#class-inheritance">4.4.2 节</a>说过,可以向任何 Ruby 类,甚至是内置的类中添加自定义的方法。那一节,我们向 <code>String</code> 类添加了 <code>palindrome?</code> 方法(而且还发现了 <code>"deified"</code> 是回文)。我们还介绍过,Rails 为 <code>Object</code> 类添加了 <code>blank?</code> 方法(所以,<code>"".blank?</code>、<code>" ".blank?</code> 和 <code>nil.blank?</code> 的返回值都是 <code>true</code>)。创建 <code>20.years.from_now</code> 之后过期的 cookie 所用的 <code>cookies.permanent</code> 方法又是一例。<code>permanent</code> 方法使用了 Rails 提供的一个时间辅助方法。时间辅助方法添加到 <code>Fixnum</code> 类(整数的基类)中:</p>
<div data-type="listing">
<div class="highlight language-irb"><pre><code><span class="go">$ rails console</span>
<span class="go">>> 1.year.from_now</span>
<span class="p">=></span> <span class="no">Wed</span><span class="p">,</span> <span class="mi">21</span> <span class="no">Jun</span> <span class="mi">2017</span> <span class="mi">19</span><span class="p">:</span><span class="mi">36</span><span class="p">:</span><span class="mi">29</span> <span class="no">UTC</span> <span class="o">+</span><span class="mo">00</span><span class="p">:</span><span class="mo">00</span>
<span class="o">>></span> <span class="mi">10</span><span class="p">.</span><span class="nf">weeks</span><span class="p">.</span><span class="nf">ago</span>
<span class="o">=></span> <span class="no">Tue</span><span class="p">,</span> <span class="mi">12</span> <span class="no">Apr</span> <span class="mi">2016</span> <span class="mi">19</span><span class="p">:</span><span class="mi">36</span><span class="p">:</span><span class="mi">44</span> <span class="no">UTC</span> <span class="o">+</span><span class="mo">00</span><span class="p">:</span><span class="mo">00</span>
</code></pre></div>
</div>
<p>Rails 还在 <code>Fixnum</code> 类中添加了其他辅助方法:</p>
<div data-type="listing">
<div class="highlight language-irb"><pre><code><span class="go">>> 1.kilobyte</span>
<span class="p">=></span> <span class="mi">1024</span>
<span class="o">>></span> <span class="mi">5</span><span class="p">.</span><span class="nf">megabytes</span>
<span class="o">=></span> <span class="mi">5242880</span>
</code></pre></div>
</div>
<p>这几个辅助方法可用于验证文件上传,例如,限制上传的图像最大不超过 <code>5.megabytes</code>。</p>
<p>这种为内置类添加方法的特性很灵便,可以扩展 Ruby 的功能,不过使用时要小心一些。其实,Rails 的很多优雅之处正是基于 Ruby 语言的这一特性实现的。</p>
</div>
<p>我们可以参照 <code>session</code> 方法(<a class="xref-link" href="chapter8.html#listing-log-in-function">代码清单 8.14</a>),使用下面的方式把用户的 ID 存入 cookie:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="n">cookies</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">id</span>
</code></pre></div>
</div>
<p>但是这种方式存储的是纯文本,攻击者很容易窃取用户的账户。为了避免这种情况发生,我们要对 cookie 签名,存入浏览器之前安全加密 cookie:<sup>[<a id="fn-ref-8" href="#fn-8">8</a>]</sup></p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">id</span>
</code></pre></div>
</div>
<p>因为我们想让用户 ID 和持久记忆令牌配对,所以也要持久存储用户 ID。为此,我们可以串联调用 <code>signed</code> 和 <code>permanent</code> 方法:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="n">cookies</span><span class="p">.</span><span class="nf">permanent</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">id</span>
</code></pre></div>
</div>
<p>存储 cookie 后,再访问页面时可以使用下面的代码检索用户:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span>
</code></pre></div>
</div>
<p>其中,<code>cookies.signed[:user_id]</code> 会自动解密 cookie 中的用户 ID。然后,再使用 bcrypt 确认 <code>cookies[:remember_token]</code> 与<a class="xref-link" href="#listing-user-model-remember">代码清单 9.3</a> 生成的 <code>remember_digest</code> 是否匹配。(你可能想知道为什么不能只使用签名的用户 ID。如果没有记忆令牌,攻击者一旦知道加密的 ID,就能以这个用户的身份登录。但是按照我们目前的设计方式,就算攻击者同时获得了用户 ID 和记忆令牌,至多只能维持登录状态到真正的用户退出。)</p>
<p>最后一步是,确认记忆令牌与用户的记忆摘要匹配。对现在这种情况来说,使用 bcrypt 确认是否匹配有很多等效的方法。如果查看<a href="https://github.com/rails/rails/blob/master/activemodel/lib/active_model/secure_password.rb" class="external-link">安全密码的源码</a>,你会发现下面这个比较语句:<sup>[<a id="fn-ref-9" href="#fn-9">9</a>]</sup></p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">password_digest</span><span class="p">)</span> <span class="o">==</span> <span class="n">unencrypted_password</span>
</code></pre></div>
</div>
<p>这里,我们需要的代码如下:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">remember_digest</span><span class="p">)</span> <span class="o">==</span> <span class="n">remember_token</span>
</code></pre></div>
</div>
<p>仔细一想,这行代码有点儿奇怪:看起来是直接比较 bcrypt 计算得到的密码哈希和令牌,那么,要使用 <code>==</code> 就得解密摘要。可是,使用 bcrypt 的目的是为了得到不可逆的哈希值,所以这么想是不对的。研究 <a href="https://github.com/codahale/bcrypt-ruby/blob/master/lib/bcrypt/password.rb" class="external-link">bcrypt gem 的源码</a>后你会发现,bcrypt 重新定义了 <code>==</code>,上述代码其实等效于:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">remember_digest</span><span class="p">).</span><span class="nf">is_password?</span><span class="p">(</span><span class="n">remember_token</span><span class="p">)</span>
</code></pre></div>
</div>
<p>这种写法没使用 <code>==</code>,而是使用返回布尔值的 <code>is_password?</code> 方法进行比较。因为这么写意思更明确,所以,在应用代码中我们将这么写。</p>
<p>基于上述分析,我们可以在 <code>User</code> 模型中定义 <code>authenticated?</code> 方法,比较摘要和令牌。这个方法的作用类似于 <code>has_secure_password</code> 提供用来认证用户的 <code>authenticate</code> 方法(<a class="xref-link" href="chapter8.html#listing-log-in-success">代码清单 8.15</a>)。<code>authenticated?</code> 方法的定义如<a class="xref-link" href="#listing-authenticated-p">代码清单 9.6</a> 所示。(虽然<a class="xref-link" href="#listing-authenticated-p">代码清单 9.6</a> 中的 <code>authenticated?</code> 方法和记忆令牌联系紧密,不过在其他情况下也很有用,<a class="xref-link" href="chapter11.html#account-activation">第 11 章</a>会改写这个方法,让它的使用范围更广。)</p>
<div id="listing-authenticated-p" data-type="listing">
<h5><span class="title-label">代码清单 9.6</span>:在 <code>User</code> 模型中添加 <code>authenticated?</code> 方法</h5>
<div class="source-file">app/models/user.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">User</span> <span class="o"><</span> <span class="no">ApplicationRecord</span>
<span class="nb">attr_accessor</span> <span class="ss">:remember_token</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="o">.</span>
<span class="c1"># 如果指定的令牌和摘要匹配,返回 true</span>
<span class="k">def</span> <span class="nf">authenticated?</span><span class="p">(</span><span class="n">remember_token</span><span class="p">)</span>
<span class="hll"> <span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">remember_digest</span><span class="p">).</span><span class="nf">is_password?</span><span class="p">(</span><span class="n">remember_token</span><span class="p">)</span></span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>注意,<code>authenticated?</code> 方法中的 <code>remember_token</code> 参数与<a class="xref-link" href="#listing-user-model-remember">代码清单 9.3</a> 中使用 <code>attr_accessor :remember_token</code> 定义的 <code>remember_token</code> 不同,它是方法内的局部变量。(这个参数指代记忆令牌,使用与方法同名的名称一点也不奇怪。)还要注意 <code>remember_digest</code> 属性的写法,这与写成 <code>self.remember_digest</code> 的作用一样;<code>self.remember_digest</code> 调用的是方法,与<a class="xref-link" href="chapter6.html#modeling-users">第 6 章</a>中的 <code>name</code> 和 <code>email</code> 类似,由 Active Record 根据数据库中的列名自动创建。</p>
<p>现在可以记住用户的登录状态了。我们要在 <code>log_in</code> 后面调用 <code>remember</code> 辅助方法,如<a class="xref-link" href="#listing-log-in-with-remember">代码清单 9.7</a> 所示。</p>
<div id="listing-log-in-with-remember" data-type="listing">
<h5><span class="title-label">代码清单 9.7</span>:登录并记住登录状态</h5>
<div class="source-file">app/controllers/sessions_controller.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">SessionsController</span> <span class="o"><</span> <span class="no">ApplicationController</span>
<span class="k">def</span> <span class="nf">new</span>
<span class="k">end</span>
<span class="k">def</span> <span class="nf">create</span>
<span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">email: </span><span class="n">params</span><span class="p">[</span><span class="ss">:session</span><span class="p">][</span><span class="ss">:email</span><span class="p">].</span><span class="nf">downcase</span><span class="p">)</span>
<span class="k">if</span> <span class="n">user</span> <span class="o">&&</span> <span class="n">user</span><span class="p">.</span><span class="nf">authenticate</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:session</span><span class="p">][</span><span class="ss">:password</span><span class="p">])</span>
<span class="n">log_in</span> <span class="n">user</span>
<span class="hll"> <span class="n">remember</span> <span class="n">user</span></span>
<span class="n">redirect_to</span> <span class="n">user</span>
<span class="k">else</span>
<span class="n">flash</span><span class="p">.</span><span class="nf">now</span><span class="p">[</span><span class="ss">:danger</span><span class="p">]</span> <span class="o">=</span> <span class="s1">'Invalid email/password combination'</span>
<span class="n">render</span> <span class="s1">'new'</span>
<span class="k">end</span>
<span class="k">end</span>
<span class="k">def</span> <span class="nf">destroy</span>
<span class="n">log_out</span>
<span class="n">redirect_to</span> <span class="n">root_url</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>与登录功能一样,<a class="xref-link" href="#listing-log-in-with-remember">代码清单 9.7</a> 把真正的工作交给 <code>Sessions</code> 辅助模块中的方法完成。在 <code>Sessions</code> 辅助模块中,我们要定义一个名为 <code>remember</code> 的方法,调用 <code>user.remember</code>,从而生成一个记忆令牌,并把对应的摘要存入数据库;然后使用 <code>cookies</code> 创建长久的 cookie,保存用户 ID 和记忆令牌。结果如<a class="xref-link" href="#listing-remember-method">代码清单 9.8</a> 所示。</p>
<div id="listing-remember-method" data-type="listing">
<h5><span class="title-label">代码清单 9.8</span>:记住用户</h5>
<div class="source-file">app/helpers/sessions_helper.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">module</span> <span class="nn">SessionsHelper</span>
<span class="c1"># 登入指定的用户</span>
<span class="k">def</span> <span class="nf">log_in</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
<span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">id</span>
<span class="k">end</span>
<span class="c1"># 在持久会话中记住用户</span>
<span class="k">def</span> <span class="nf">remember</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
<span class="hll"> <span class="n">user</span><span class="p">.</span><span class="nf">remember</span></span>
<span class="hll"> <span class="n">cookies</span><span class="p">.</span><span class="nf">permanent</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">id</span></span>
<span class="hll"> <span class="n">cookies</span><span class="p">.</span><span class="nf">permanent</span><span class="p">[</span><span class="ss">:remember_token</span><span class="p">]</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">remember_token</span></span>
<span class="k">end</span>
<span class="c1"># 返回当前登录的用户(如果有的话)</span>
<span class="k">def</span> <span class="nf">current_user</span>
<span class="k">if</span> <span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span>
<span class="vi">@current_user</span> <span class="o">||=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span>
<span class="k">end</span>
<span class="k">end</span>
<span class="c1"># 如果用户已登录,返回 true,否则返回 false</span>
<span class="k">def</span> <span class="nf">logged_in?</span>
<span class="o">!</span><span class="n">current_user</span><span class="p">.</span><span class="nf">nil?</span>
<span class="k">end</span>
<span class="c1"># 退出当前用户</span>
<span class="k">def</span> <span class="nf">log_out</span>
<span class="n">session</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:user_id</span><span class="p">)</span>
<span class="vi">@current_user</span> <span class="o">=</span> <span class="kp">nil</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>现在,用户登录后会被记住,因为在浏览器中存储了有效的记忆令牌。但是这还没有什么实际作用,因为<a class="xref-link" href="chapter8.html#listing-current-user">代码清单 8.16</a> 中定义的 <code>current_user</code> 方法只能处理临时会话:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="vi">@current_user</span> <span class="o">||=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span>
</code></pre></div>
</div>
<p>对持久会话来说,如果临时会话中有 <code>session[:user_id]</code>,那么使用它检索用户;否则,应该检查 <code>cookies[:user_id]</code>,检索(并登入)持久会话中存储的用户。实现方式如下:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="k">if</span> <span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span>
<span class="vi">@current_user</span> <span class="o">||=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span>
<span class="k">elsif</span> <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span>
<span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span>
<span class="k">if</span> <span class="n">user</span> <span class="o">&&</span> <span class="n">user</span><span class="p">.</span><span class="nf">authenticated?</span><span class="p">(</span><span class="n">cookies</span><span class="p">[</span><span class="ss">:remember_token</span><span class="p">])</span>
<span class="n">log_in</span> <span class="n">user</span>
<span class="vi">@current_user</span> <span class="o">=</span> <span class="n">user</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>(这里沿用了<a class="xref-link" href="chapter8.html#listing-find-authenticate-user">代码清单 8.7</a> 中使用的 <code>user && user.authenticated</code> 模式。)上述代码是可以使用,但注意,其中重复使用了 <code>session</code> 和 <code>cookies</code>。我们可以去除重复,写成这样:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="hll"><span class="k">if</span> <span class="p">(</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span></span>
<span class="vi">@current_user</span> <span class="o">||=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">user_id</span><span class="p">)</span>
<span class="hll"><span class="k">elsif</span> <span class="p">(</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span></span>
<span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">user_id</span><span class="p">)</span>
<span class="k">if</span> <span class="n">user</span> <span class="o">&&</span> <span class="n">user</span><span class="p">.</span><span class="nf">authenticated?</span><span class="p">(</span><span class="n">cookies</span><span class="p">[</span><span class="ss">:remember_token</span><span class="p">])</span>
<span class="n">log_in</span> <span class="n">user</span>
<span class="vi">@current_user</span> <span class="o">=</span> <span class="n">user</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>改写后使用了常见但有点儿让人困惑的结构:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="k">if</span> <span class="p">(</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span>
</code></pre></div>
</div>
<p>别被外观迷惑了,这不是比较语句(比较时应该使用双等号 <code>==</code>),而是赋值语句。如果读出来,不能念成“如果用户 ID 等于会话中的用户 ID”,应该是“如果会话中有用户的 ID,把会话中的 ID 赋值给 <code>user_id</code>”。<sup>[<a id="fn-ref-10" href="#fn-10">10</a>]</sup></p>
<p>按照上述分析定义 <code>current_user</code> 辅助方法,如<a class="xref-link" href="#listing-persistent-current-user">代码清单 9.9</a> 所示。</p>
<div id="listing-persistent-current-user" data-type="listing">
<h5><span class="title-label">代码清单 9.9</span>:更新 <code>current_user</code> 方法,支持持久会话 <span class="red">RED</span></h5>
<div class="source-file">app/helpers/sessions_helper.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">module</span> <span class="nn">SessionsHelper</span>
<span class="c1"># 登入指定的用户</span>
<span class="k">def</span> <span class="nf">log_in</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
<span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">id</span>
<span class="k">end</span>
<span class="c1"># 在持久会话中记住用户</span>
<span class="k">def</span> <span class="nf">remember</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
<span class="n">user</span><span class="p">.</span><span class="nf">remember</span>
<span class="n">cookies</span><span class="p">.</span><span class="nf">permanent</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">id</span>
<span class="n">cookies</span><span class="p">.</span><span class="nf">permanent</span><span class="p">[</span><span class="ss">:remember_token</span><span class="p">]</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">remember_token</span>
<span class="k">end</span>
<span class="c1"># 返回 cookie 中记忆令牌对应的用户</span>
<span class="k">def</span> <span class="nf">current_user</span>
<span class="hll"> <span class="k">if</span> <span class="p">(</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span></span>
<span class="hll"> <span class="vi">@current_user</span> <span class="o">||=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">user_id</span><span class="p">)</span></span>
<span class="hll"> <span class="k">elsif</span> <span class="p">(</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span></span>
<span class="hll"> <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">user_id</span><span class="p">)</span></span>
<span class="hll"> <span class="k">if</span> <span class="n">user</span> <span class="o">&&</span> <span class="n">user</span><span class="p">.</span><span class="nf">authenticated?</span><span class="p">(</span><span class="n">cookies</span><span class="p">[</span><span class="ss">:remember_token</span><span class="p">])</span></span>
<span class="hll"> <span class="n">log_in</span> <span class="n">user</span></span>
<span class="hll"> <span class="vi">@current_user</span> <span class="o">=</span> <span class="n">user</span></span>
<span class="hll"> <span class="k">end</span></span>
<span class="hll"> <span class="k">end</span></span>
<span class="k">end</span>
<span class="c1"># 如果用户已登录,返回 true,否则返回 false</span>
<span class="k">def</span> <span class="nf">logged_in?</span>
<span class="o">!</span><span class="n">current_user</span><span class="p">.</span><span class="nf">nil?</span>
<span class="k">end</span>
<span class="c1"># 退出当前用户</span>
<span class="k">def</span> <span class="nf">log_out</span>
<span class="n">session</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:user_id</span><span class="p">)</span>
<span class="vi">@current_user</span> <span class="o">=</span> <span class="kp">nil</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>现在,新登录的用户能正确记住登录状态了。你可以确认一下:登录后关闭浏览器,再打开浏览器,重新访问演示应用,检查是否还是已登录状态。如果愿意,甚至还可以直接查看浏览器中的 cookie,如<a class="xref-link" href="#fig-cookie-in-browser">图 9.2</a> 所示。<sup>[<a id="fn-ref-11" href="#fn-11">11</a>]</sup></p>
<div data-type="sidebar" class="sidebar">
<p>细心的读者 Jack Fahnestock 注意到,目前的设计有一个边缘情况未覆盖:</p>
<ol class="arabic">
<li>
<p>在浏览器 A 中登录,勾选“记住我”(把记忆令牌的哈希值 A 存入 <code>remember_digest</code>)。</p>
</li>
<li>
<p>在浏览器 B 中登录,勾选“记住我”(把记忆令牌的哈希值 B 存入 <code>remember_digest</code>,覆盖了浏览器 A 存储的哈希值 A)。</p>
</li>
<li>
<p>关闭浏览器 A(现在依赖永久 cookie 登录,即 <code>current_user</code> 方法中的第二个条件)。</p>
</li>
<li>
<p>重新打开浏览器 A(尽管浏览器中有永久 cookie,<code>logged_in?</code> 依然返回 <code>false</code>)。</p>
</li>
</ol>
<p>虽然这样设计可能比在多处记住用户更安全,但是没有考虑到一个基本的事实,即用户可以在多个浏览器中选择永久记住登录状态。这个问题的解决方法是(比目前的设计方式复杂得多),在单独的数据表中存储记忆摘要,各行包含用户的 ID 和摘要。检查当前用户时要遍寻这个表,找出与特定记忆令牌对应的摘要。而且,<a class="xref-link" href="#listing-user-model-forget">代码清单 9.11</a> 中的 <code>forget</code> 方法只能删除与当前浏览器对应的摘要相关的行。出于安全考虑,退出时则要删除那个用户的全部摘要。</p>
</div>
<div id="fig-cookie-in-browser" class="figure"><img src="images/chapter9/cookie_in_browser_chrome.png" alt="cookie in browser chrome" /><div class="figcaption"><span class="title-label">图 9.2</span>:本地浏览器 cookie 中存储的记忆令牌</div></div>
<p>现在我们的应用还有一个问题:无法清除浏览器中的 cookie(除非等到 20 年后),因此用户无法退出。这正是测试应该捕获的问题,而且目前测试的确无法通过:</p>
<div data-type="listing">
<h5><span class="title-label">代码清单 9.10</span>:<strong class="red">RED</strong></h5>
<div class="highlight language-sh"><pre><code><span class="nv">$ </span>rails <span class="nb">test</span>
</code></pre></div>
</div>
<h5 id="exercises-login-with-remembering" class="discrete">练习</h5>
<ol class="arabic">
<li>
<p>在你的浏览器中查看 cookie,确认登录后 cookie 中有记忆令牌和加密的用户 ID。</p>
</li>
<li>
<p>直接在控制台中确认<a class="xref-link" href="#listing-authenticated-p">代码清单 9.6</a> 中定义的 <code>authenticated?</code> 方法行为正确。</p>
</li>
</ol>
</section>
<section data-type="sect2" id="forgetting-users">
<h2><span class="title-label">9.1.3</span> 忘记用户</h2>
<p>为了让用户退出,我们要定义一些和记住用户相对的方法,忘记用户。最终实现的 <code>user.forget</code> 方法,把记忆摘要的值设为 <code>nil</code>,即撤销 <code>user.remember</code> 方法的操作,如<a class="xref-link" href="#listing-user-model-forget">代码清单 9.11</a> 所示。</p>
<div id="listing-user-model-forget" data-type="listing">
<h5><span class="title-label">代码清单 9.11</span>:在 <code>User</code> 模型中添加 <code>forget</code> 方法 <span class="red">RED</span></h5>
<div class="source-file">app/models/user.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">User</span> <span class="o"><</span> <span class="no">ApplicationRecord</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="o">.</span>
<span class="c1"># 如果指定的令牌和摘要匹配,返回 true</span>
<span class="k">def</span> <span class="nf">authenticated?</span><span class="p">(</span><span class="n">remember_token</span><span class="p">)</span>
<span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">remember_digest</span><span class="p">).</span><span class="nf">is_password?</span><span class="p">(</span><span class="n">remember_token</span><span class="p">)</span>
<span class="k">end</span>
<span class="c1"># 忘记用户</span>
<span class="k">def</span> <span class="nf">forget</span>
<span class="hll"> <span class="n">update_attribute</span><span class="p">(</span><span class="ss">:remember_digest</span><span class="p">,</span> <span class="kp">nil</span><span class="p">)</span></span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>然后我们可以定义 <code>forget</code> 辅助方法,忘记持久会话,然后在 <code>log_out</code> 辅助方法中调用 <code>forget</code>,如<a class="xref-link" href="#listing-log-out-with-forget">代码清单 9.12</a> 所示。<code>forget</code> 方法先调用 <code>user.forget</code>,然后再从 cookie 中删除 <code>user_id</code> 和 <code>remember_token</code>。</p>
<div id="listing-log-out-with-forget" data-type="listing">
<h5><span class="title-label">代码清单 9.12</span>:退出持久会话 <span class="green">GREEN</span></h5>
<div class="source-file">app/helpers/sessions_helper.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">module</span> <span class="nn">SessionsHelper</span>
<span class="c1"># 登入指定的用户</span>
<span class="k">def</span> <span class="nf">log_in</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
<span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="nf">id</span>
<span class="k">end</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="o">.</span>
<span class="c1"># 忘记持久会话</span>
<span class="k">def</span> <span class="nf">forget</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
<span class="hll"> <span class="n">user</span><span class="p">.</span><span class="nf">forget</span></span>
<span class="hll"> <span class="n">cookies</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:user_id</span><span class="p">)</span></span>
<span class="hll"> <span class="n">cookies</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:remember_token</span><span class="p">)</span></span>
<span class="k">end</span>
<span class="c1"># 退出当前用户</span>
<span class="k">def</span> <span class="nf">log_out</span>
<span class="hll"> <span class="n">forget</span><span class="p">(</span><span class="n">current_user</span><span class="p">)</span></span>
<span class="n">session</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:user_id</span><span class="p">)</span>
<span class="vi">@current_user</span> <span class="o">=</span> <span class="kp">nil</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>此时,测试组件应该可以通过:</p>
<div data-type="listing">
<h5><span class="title-label">代码清单 9.13</span>:<strong class="green">GREEN</strong></h5>
<div class="highlight language-sh"><pre><code><span class="nv">$ </span>rails <span class="nb">test</span>
</code></pre></div>
</div>
<h5 id="exercises-forgetting-users" class="discrete">练习</h5>
<ol class="arabic">
<li>
<p>退出后在你的浏览器中确认相应的 cookie 被删除了。</p>
</li>
</ol>
</section>
<section data-type="sect2" id="two-subtle-bugs">
<h2><span class="title-label">9.1.4</span> 两个小问题</h2>
<p>现在还有两个相互之间有关系的小问题待解决。第一个,虽然只有登录后才能看到退出链接,但一个用户可能会同时打开多个浏览器窗口访问网站,如果用户在一个窗口中退出了,再在另一个窗口中点击退出链接的话会导致错误,这是因为 <code>log_out</code> 方法中使用了 <code>forget(current_user)</code>(<a class="xref-link" href="#listing-log-out-with-forget">代码清单 9.12</a>)。<sup>[<a id="fn-ref-12" href="#fn-12">12</a>]</sup>我们可以限制只有已登录的用户才能退出,从而解决这个问题。</p>
<p>第二个问题,用户可能会在不同的浏览器中登录(登录状态被记住),例如 Chrome 和 Firefox,如果用户在一个浏览器中退出,而另一个浏览器中没有退出,就会导致问题。<sup>[<a id="fn-ref-13" href="#fn-13">13</a>]</sup>假如用户在 Firefox 中退出了,那么记忆摘要的值变成了 <code>nil</code>(通过<a class="xref-link" href="#listing-user-model-forget">代码清单 9.11</a> 中的 <code>user.forget</code>)。在 Firefox 中没什么问题,因为<a class="xref-link" href="#listing-log-out-with-forget">代码清单 9.12</a> 中的 <code>log_out</code> 方法删除了用户的 ID,所以下面高亮的两行判断的结果都是 <code>false</code>:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="c1"># 返回 cookie 中记忆令牌对应的用户</span>
<span class="k">def</span> <span class="nf">current_user</span>
<span class="hll"> <span class="k">if</span> <span class="p">(</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span></span>
<span class="vi">@current_user</span> <span class="o">||=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">user_id</span><span class="p">)</span>
<span class="hll"> <span class="k">elsif</span> <span class="p">(</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span></span>
<span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">user_id</span><span class="p">)</span>
<span class="k">if</span> <span class="n">user</span> <span class="o">&&</span> <span class="n">user</span><span class="p">.</span><span class="nf">authenticated?</span><span class="p">(</span><span class="n">cookies</span><span class="p">[</span><span class="ss">:remember_token</span><span class="p">])</span>
<span class="n">log_in</span> <span class="n">user</span>
<span class="vi">@current_user</span> <span class="o">=</span> <span class="n">user</span>
<span class="k">end</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>结果是,代码运行到 <code>current_user</code> 方法的末尾,返回 <code>nil</code>。</p>
<p>而如果关闭了 Chrome,<code>session[:user_id]</code> 会变成 <code>nil</code>(因为关闭浏览器后 <code>session</code> 中的值自动过期),但是 cookie 中的用户 ID 仍然存在。这意味着,重启 Chrome 后,还会从数据库中获取相应的用户:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="c1"># 返回 cookie 中记忆令牌对应的用户</span>
<span class="k">def</span> <span class="nf">current_user</span>
<span class="k">if</span> <span class="p">(</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">session</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span>
<span class="vi">@current_user</span> <span class="o">||=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">user_id</span><span class="p">)</span>
<span class="hll"> <span class="k">elsif</span> <span class="p">(</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:user_id</span><span class="p">])</span></span>
<span class="hll"> <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">user_id</span><span class="p">)</span></span>
<span class="hll"> <span class="k">if</span> <span class="n">user</span> <span class="o">&&</span> <span class="n">user</span><span class="p">.</span><span class="nf">authenticated?</span><span class="p">(</span><span class="n">cookies</span><span class="p">[</span><span class="ss">:remember_token</span><span class="p">])</span></span>
<span class="n">log_in</span> <span class="n">user</span>
<span class="vi">@current_user</span> <span class="o">=</span> <span class="n">user</span>
<span class="k">end</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>因此,内层 <code>if</code> 条件语句会执行下述表达式:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="n">user</span> <span class="o">&&</span> <span class="n">user</span><span class="p">.</span><span class="nf">authenticated?</span><span class="p">(</span><span class="n">cookies</span><span class="p">[</span><span class="ss">:remember_token</span><span class="p">])</span>
</code></pre></div>
</div>
<p>因为 <code>user</code> 不是 <code>nil</code>,第二个表达式会执行,从而导致错误抛出。这是因为在 Firefox 中退出时记忆摘要被删除了(<a class="xref-link" href="#listing-user-model-forget">代码清单 9.11</a>),在 Chrome 中访问应用时调用下述代码传入的记忆摘要是 <code>nil</code>,导致 bcrypt 抛出异常:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">remember_digest</span><span class="p">).</span><span class="nf">is_password?</span><span class="p">(</span><span class="n">remember_token</span><span class="p">)</span>
</code></pre></div>
</div>
<p>若想解决这个问题,<code>authenticated?</code> 方法要返回 <code>false</code>。</p>
<p>这正是测试驱动开发的优势所在,所以在解决之前,我们先编写测试捕获这两个小问题。我们先让<a class="xref-link" href="chapter8.html#listing-user-logout-test">代码清单 8.31</a> 中的集成测试失败,如<a class="xref-link" href="#listing-test-double-logout">代码清单 9.14</a> 所示。</p>
<div id="listing-test-double-logout" data-type="listing">
<h5><span class="title-label">代码清单 9.14</span>:测试用户退出 <span class="red">RED</span></h5>
<div class="source-file">test/integration/users_login_test.rb</div>
<div class="highlight language-ruby"><pre><code><span class="nb">require</span> <span class="s1">'test_helper'</span>
<span class="k">class</span> <span class="nc">UsersLoginTest</span> <span class="o"><</span> <span class="no">ActionDispatch</span><span class="o">::</span><span class="no">IntegrationTest</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="p">.</span>
<span class="nf">test</span> <span class="s2">"login with valid information followed by logout"</span> <span class="k">do</span>
<span class="n">get</span> <span class="n">login_path</span>
<span class="n">post</span> <span class="n">login_path</span><span class="p">,</span> <span class="ss">params: </span><span class="p">{</span> <span class="ss">session: </span><span class="p">{</span> <span class="ss">email: </span><span class="vi">@user</span><span class="p">.</span><span class="nf">email</span><span class="p">,</span>
<span class="ss">password: </span><span class="s1">'password'</span> <span class="p">}</span> <span class="p">}</span>
<span class="n">assert</span> <span class="n">is_logged_in?</span>
<span class="n">assert_redirected_to</span> <span class="vi">@user</span>
<span class="n">follow_redirect!</span>
<span class="n">assert_template</span> <span class="s1">'users/show'</span>
<span class="n">assert_select</span> <span class="s2">"a[href=?]"</span><span class="p">,</span> <span class="n">login_path</span><span class="p">,</span> <span class="ss">count: </span><span class="mi">0</span>
<span class="n">assert_select</span> <span class="s2">"a[href=?]"</span><span class="p">,</span> <span class="n">logout_path</span>
<span class="n">assert_select</span> <span class="s2">"a[href=?]"</span><span class="p">,</span> <span class="n">user_path</span><span class="p">(</span><span class="vi">@user</span><span class="p">)</span>
<span class="n">delete</span> <span class="n">logout_path</span>
<span class="n">assert_not</span> <span class="n">is_logged_in?</span>
<span class="n">assert_redirected_to</span> <span class="n">root_url</span>
<span class="hll"> <span class="c1"># 模拟用户在另一个窗口中点击退出链接</span></span>
<span class="hll"> <span class="n">delete</span> <span class="n">logout_path</span></span>
<span class="n">follow_redirect!</span>
<span class="n">assert_select</span> <span class="s2">"a[href=?]"</span><span class="p">,</span> <span class="n">login_path</span>
<span class="n">assert_select</span> <span class="s2">"a[href=?]"</span><span class="p">,</span> <span class="n">logout_path</span><span class="p">,</span> <span class="ss">count: </span><span class="mi">0</span>
<span class="n">assert_select</span> <span class="s2">"a[href=?]"</span><span class="p">,</span> <span class="n">user_path</span><span class="p">(</span><span class="vi">@user</span><span class="p">),</span> <span class="ss">count: </span><span class="mi">0</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>第二个 <code>delete logout_path</code> 会抛出异常,因为没有当前用户,从而导致测试组件无法通过:</p>
<div data-type="listing">
<h5><span class="title-label">代码清单 9.15</span>:<strong class="red">RED</strong></h5>
<div class="highlight language-sh"><pre><code><span class="nv">$ </span>rails <span class="nb">test</span>
</code></pre></div>
</div>
<p>在应用代码中,我们只需在 <code>logged_in?</code> 返回 <code>true</code> 时调用 <code>log_out</code> 即可,如<a class="xref-link" href="#listing-destroy-forget">代码清单 9.16</a> 所示。</p>
<div id="listing-destroy-forget" data-type="listing">
<h5><span class="title-label">代码清单 9.16</span>:只有登录后才能退出 <span class="green">GREEN</span></h5>
<div class="source-file">app/controllers/sessions_controller.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">SessionsController</span> <span class="o"><</span> <span class="no">ApplicationController</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="p">.</span>
<span class="nf">def</span> <span class="n">destroy</span>
<span class="hll"> <span class="n">log_out</span> <span class="k">if</span> <span class="n">logged_in?</span></span>
<span class="n">redirect_to</span> <span class="n">root_url</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>第二个问题涉及到两种不同的浏览器,在集成测试中很难模拟,不过直接在 <code>User</code> 模型层测试很简单。我们只需创建一个没有记忆摘要的用户(<code>setup</code> 方法中定义的 <code>@user</code> 变量就没有),再调用 <code>authenticated?</code> 方法即可,如<a class="xref-link" href="#listing-test-authenticated-invalid-token">代码清单 9.17</a> 所示。(注意,我们直接使用空记忆令牌,因为还没用到这个值之前就会发生错误。)</p>
<div id="listing-test-authenticated-invalid-token" data-type="listing">
<h5><span class="title-label">代码清单 9.17</span>:测试没有摘要时 <code>authenticated?</code> 方法的行为 <span class="red">RED</span></h5>
<div class="source-file">test/models/user_test.rb</div>
<div class="highlight language-ruby"><pre><code><span class="nb">require</span> <span class="s1">'test_helper'</span>
<span class="k">class</span> <span class="nc">UserTest</span> <span class="o"><</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">TestCase</span>
<span class="k">def</span> <span class="nf">setup</span>
<span class="vi">@user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">name: </span><span class="s2">"Example User"</span><span class="p">,</span> <span class="ss">email: </span><span class="s2">"user@example.com"</span><span class="p">,</span>
<span class="ss">password: </span><span class="s2">"foobar"</span><span class="p">,</span> <span class="ss">password_confirmation: </span><span class="s2">"foobar"</span><span class="p">)</span>
<span class="k">end</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="p">.</span>
<span class="hll"> <span class="nf">test</span> <span class="s2">"authenticated? should return false for a user with nil digest"</span> <span class="k">do</span></span>
<span class="hll"> <span class="n">assert_not</span> <span class="vi">@user</span><span class="p">.</span><span class="nf">authenticated?</span><span class="p">(</span><span class="s1">''</span><span class="p">)</span></span>
<span class="hll"> <span class="k">end</span></span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>因为 <code>BCrypt::Password.new(nil)</code> 会抛出异常,所以测试组件不能通过:</p>
<div data-type="listing">
<h5><span class="title-label">代码清单 9.18</span>:<strong class="red">RED</strong></h5>
<div class="highlight language-sh"><pre><code><span class="nv">$ </span>rails <span class="nb">test</span>
</code></pre></div>
</div>
<p>为了修正这个问题,让测试通过,记忆摘要的值为 <code>nil</code> 时,<code>authenticated?</code> 要返回 <code>false</code>,如<a class="xref-link" href="#listing-authenticated-p-fixed">代码清单 9.19</a> 所示。</p>
<div id="listing-authenticated-p-fixed" data-type="listing">
<h5><span class="title-label">代码清单 9.19</span>:更新 <code>authenticated?</code> 方法,处理没有记忆摘要的情况 <span class="green">GREEN</span></h5>
<div class="source-file">app/models/user.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">User</span> <span class="o"><</span> <span class="no">ApplicationRecord</span>
<span class="p">.</span>
<span class="nf">.</span>
<span class="o">.</span>
<span class="c1"># 如果指定的令牌和摘要匹配,返回 true</span>
<span class="k">def</span> <span class="nf">authenticated?</span><span class="p">(</span><span class="n">remember_token</span><span class="p">)</span>
<span class="hll"> <span class="k">return</span> <span class="kp">false</span> <span class="k">if</span> <span class="n">remember_digest</span><span class="p">.</span><span class="nf">nil?</span></span>
<span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">remember_digest</span><span class="p">).</span><span class="nf">is_password?</span><span class="p">(</span><span class="n">remember_token</span><span class="p">)</span>
<span class="k">end</span>
<span class="c1"># 忘记用户</span>
<span class="k">def</span> <span class="nf">forget</span>
<span class="n">update_attribute</span><span class="p">(</span><span class="ss">:remember_digest</span><span class="p">,</span> <span class="kp">nil</span><span class="p">)</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>如果记忆摘要的值为 <code>nil</code>,直接使用 <code>return</code> 关键字返回。这种方式经常用到,目的是强调其后的代码会被忽略。等价的代码如下:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="k">if</span> <span class="n">remember_digest</span><span class="p">.</span><span class="nf">nil?</span>
<span class="kp">false</span>
<span class="k">else</span>
<span class="no">BCrypt</span><span class="o">::</span><span class="no">Password</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">remember_digest</span><span class="p">).</span><span class="nf">is_password?</span><span class="p">(</span><span class="n">remember_token</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>这样写也行,但我喜欢明确返回的版本,而且也稍微简短一些。</p>
<p>像<a class="xref-link" href="#listing-authenticated-p-fixed">代码清单 9.19</a> 那样修改之后,测试组件应该可以通过了,说明这两个小问题都解决了:</p>
<div data-type="listing">
<h5><span class="title-label">代码清单 9.20</span>:<strong class="green">GREEN</strong></h5>
<div class="highlight language-sh"><pre><code><span class="nv">$ </span>rails <span class="nb">test</span>
</code></pre></div>
</div>
<h5 id="exercises-two-subtle-bugs" class="discrete">练习</h5>
<ol class="arabic">
<li>
<p>把<a class="xref-link" href="#listing-destroy-forget">代码清单 9.16</a> 中添加的代码注释掉,在已登录状态下打开两个浏览器标签页,在一个标签页中退出,再点击另一个标签页中的“Log out”(退出)链接,确认第一个小问题存在。</p>
</li>
<li>
<p>把<a class="xref-link" href="#listing-authenticated-p-fixed">代码清单 9.19</a> 中添加的代码注释掉,然后在一个浏览器中退出,再打开另一个浏览器,确认第二个小问题存在。</p>
</li>
<li>
<p>把前两题的注释改回去,确认测试组件又可以通过了。</p>
</li>
</ol>
</section>
</section>
<section data-type="sect1" id="remember-me-checkbox">
<h1><span class="title-label">9.2</span> “记住我”复选框</h1>
<p>至此,我们的应用已经实现了完整且专业的身份验证系统。最后,我们来看一下如何使用“记住我”复选框让用户选择是否记住登录状态。包含这个复选框的登录表单构思图如<a class="xref-link" href="#fig-login-remember-me-mockup">图 9.3</a> 所示。</p>
<p>为了实现这个构思,我们首先要在登录表单(<a class="xref-link" href="chapter8.html#listing-login-form">代码清单 8.4</a>)中添加一个复选框。与标注(label)、文本字段、密码字段和提交按钮一样,复选框也可以使用 Rails 提供的辅助方法创建。不过,为了得到正确的样式,我们要把复选框嵌套在标注中,如下所示:</p>
<div data-type="listing">
<div class="highlight language-erb"><pre><code><span class="cp"><%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:remember_me</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"checkbox inline"</span> <span class="k">do</span> <span class="cp">%></span>
<span class="cp"><%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">check_box</span> <span class="ss">:remember_me</span> <span class="cp">%></span>
<span class="nt"><span></span>Remember me on this computer<span class="nt"></span></span>
<span class="cp"><%</span> <span class="k">end</span> <span class="cp">%></span>
</code></pre></div>
</div>
<p>把这段代码添加到登录表单,得到的视图如<a class="xref-link" href="#listing-remember-me-checkbox">代码清单 9.21</a> 所示。</p>
<div id="fig-login-remember-me-mockup" class="figure"><img src="images/chapter9/login_remember_me_mockup.png" alt="login remember me mockup" /><div class="figcaption"><span class="title-label">图 9.3</span>:构思“记住我”复选框</div></div>
<div id="listing-remember-me-checkbox" data-type="listing">
<h5><span class="title-label">代码清单 9.21</span>:在登录表单中添加“记住我”复选框</h5>
<div class="source-file">app/views/sessions/new.html.erb</div>
<div class="highlight language-erb"><pre><code><span class="cp"><%</span> <span class="n">provide</span><span class="p">(</span><span class="ss">:title</span><span class="p">,</span> <span class="s2">"Log in"</span><span class="p">)</span> <span class="cp">%></span>
<span class="nt"><h1></span>Log in<span class="nt"></h1></span>
<span class="nt"><div</span> <span class="na">class=</span><span class="s">"row"</span><span class="nt">></span>
<span class="nt"><div</span> <span class="na">class=</span><span class="s">"col-md-6 col-md-offset-3"</span><span class="nt">></span>
<span class="cp"><%=</span> <span class="n">form_for</span><span class="p">(</span><span class="ss">:session</span><span class="p">,</span> <span class="ss">url: </span><span class="n">login_path</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">f</span><span class="o">|</span> <span class="cp">%></span>
<span class="cp"><%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:email</span> <span class="cp">%></span>
<span class="cp"><%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">email_field</span> <span class="ss">:email</span><span class="p">,</span> <span class="ss">class: </span><span class="s1">'form-control'</span> <span class="cp">%></span>
<span class="cp"><%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:password</span> <span class="cp">%></span>
<span class="cp"><%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">password_field</span> <span class="ss">:password</span><span class="p">,</span> <span class="ss">class: </span><span class="s1">'form-control'</span> <span class="cp">%></span>
<span class="hll"> <span class="cp"><%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:remember_me</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"checkbox inline"</span> <span class="k">do</span> <span class="cp">%></span></span>
<span class="hll"> <span class="cp"><%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">check_box</span> <span class="ss">:remember_me</span> <span class="cp">%></span></span>
<span class="hll"> <span class="nt"><span></span>Remember me on this computer<span class="nt"></span></span></span>
<span class="hll"> <span class="cp"><%</span> <span class="k">end</span> <span class="cp">%></span></span>
<span class="cp"><%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">submit</span> <span class="s2">"Log in"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"btn btn-primary"</span> <span class="cp">%></span>
<span class="cp"><%</span> <span class="k">end</span> <span class="cp">%></span>
<span class="nt"><p></span>New user? <span class="cp"><%=</span> <span class="n">link_to</span> <span class="s2">"Sign up now!"</span><span class="p">,</span> <span class="n">signup_path</span> <span class="cp">%></span><span class="nt"></p></span>
<span class="nt"></div></span>
<span class="nt"></div></span>
</code></pre></div>
</div>
<p><a class="xref-link" href="#listing-remember-me-checkbox">代码清单 9.21</a> 中使用了 CSS 类 <code>checkbox</code> 和 <code>inline</code>,Bootstrap 使用这两个类把复选框和文本(“Remember me on this computer”)放在同一行。为了完善样式,我们还要再定义一些 CSS 规则,如<a class="xref-link" href="#listing-remember-me-css">代码清单 9.22</a> 所示。得到的登录表单如<a class="xref-link" href="#fig-login-form-remember-me">图 9.4</a> 所示。</p>
<div id="fig-login-form-remember-me" class="figure"><img src="images/chapter9/login_form_remember_me.png" alt="login form remember me" /><div class="figcaption"><span class="title-label">图 9.4</span>:添加“记住我”复选框后的登录表单</div></div>
<div id="listing-remember-me-css" data-type="listing">
<h5><span class="title-label">代码清单 9.22</span>:“记住我”复选框的 CSS 规则</h5>
<div class="source-file">app/assets/stylesheets/custom.scss</div>
<div class="highlight language-scss"><pre><code><span class="nc">.</span>
<span class="nc">.</span>
<span class="nc">.</span>
<span class="o">/*</span> <span class="nt">forms</span> <span class="o">*/</span>
<span class="nc">.</span>
<span class="nc">.</span>
<span class="nc">.</span>
<span class="nc">.checkbox</span> <span class="p">{</span>
<span class="nl">margin-top</span><span class="p">:</span> <span class="m">-10px</span><span class="p">;</span>
<span class="nl">margin-bottom</span><span class="p">:</span> <span class="m">10px</span><span class="p">;</span>
<span class="nt">span</span> <span class="p">{</span>
<span class="nl">margin-left</span><span class="p">:</span> <span class="m">20px</span><span class="p">;</span>
<span class="nl">font-weight</span><span class="p">:</span> <span class="nb">normal</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="nn">#session_remember_me</span> <span class="p">{</span>
<span class="nl">width</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span>
<span class="nl">margin-left</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>
</div>
<p>修改登录表单后,当用户勾选这个复选框时,记住用户的登录状态,否则不记住。因为前一节的工作做得很好,现在实现起来只需一行代码就行。提交登录表单后,<code>params</code> 散列中包含一个基于复选框状态的值(你可以使用有效信息填写登录表单,然后提交,看一下页面底部的调试信息)。如果勾选了复选框,<code>params[:session][:remember_me]</code> 的值是 <code>'1'</code>,否则是 <code>'0'</code>。</p>
<p>我们可以检查 <code>params</code> 散列中相关的值,根据提交的值决定是否记住用户:<sup>[<a id="fn-ref-14" href="#fn-14">14</a>]</sup></p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="k">if</span> <span class="n">params</span><span class="p">[</span><span class="ss">:session</span><span class="p">][</span><span class="ss">:remember_me</span><span class="p">]</span> <span class="o">==</span> <span class="s1">'1'</span>
<span class="n">remember</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
<span class="k">else</span>
<span class="n">forget</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>根据<a class="xref-link" href="#aside-ternary-operator">旁注 9.2</a> 中的说明,这种 <code>if-then</code> 分支语句可以使用三元运算符(ternary operator)变成一行:<sup>[<a id="fn-ref-15" href="#fn-15">15</a>]</sup></p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="n">params</span><span class="p">[</span><span class="ss">:session</span><span class="p">][</span><span class="ss">:remember_me</span><span class="p">]</span> <span class="o">==</span> <span class="s1">'1'</span> <span class="p">?</span> <span class="n">remember</span><span class="p">(</span><span class="n">user</span><span class="p">)</span> <span class="p">:</span> <span class="n">forget</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
</code></pre></div>
</div>
<p>把 <code>Sessions</code> 控制器 <code>create</code> 动作(<a class="xref-link" href="#listing-log-in-with-remember">代码清单 9.7</a>)中的 <code>remember user</code> 替换成这行代码之后,得到的代码非常简洁,如<a class="xref-link" href="#listing-remember-me-ternary">代码清单 9.23</a> 所示。(现在你应该可以理解<a class="xref-link" href="chapter8.html#listing-digest-method">代码清单 8.21</a> 中使用三元运算符定义 <code>cost</code> 变量的代码了。)</p>
<div id="listing-remember-me-ternary" data-type="listing">
<h5><span class="title-label">代码清单 9.23</span>:处理提交的“记住我”复选框</h5>
<div class="source-file">app/controllers/sessions_controller.rb</div>
<div class="highlight language-ruby"><pre><code><span class="k">class</span> <span class="nc">SessionsController</span> <span class="o"><</span> <span class="no">ApplicationController</span>
<span class="k">def</span> <span class="nf">new</span>
<span class="k">end</span>
<span class="k">def</span> <span class="nf">create</span>
<span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">email: </span><span class="n">params</span><span class="p">[</span><span class="ss">:session</span><span class="p">][</span><span class="ss">:email</span><span class="p">].</span><span class="nf">downcase</span><span class="p">)</span>
<span class="k">if</span> <span class="n">user</span> <span class="o">&&</span> <span class="n">user</span><span class="p">.</span><span class="nf">authenticate</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:session</span><span class="p">][</span><span class="ss">:password</span><span class="p">])</span>
<span class="n">log_in</span> <span class="n">user</span>
<span class="hll"> <span class="n">params</span><span class="p">[</span><span class="ss">:session</span><span class="p">][</span><span class="ss">:remember_me</span><span class="p">]</span> <span class="o">==</span> <span class="s1">'1'</span> <span class="p">?</span> <span class="n">remember</span><span class="p">(</span><span class="n">user</span><span class="p">)</span> <span class="p">:</span> <span class="n">forget</span><span class="p">(</span><span class="n">user</span><span class="p">)</span></span>
<span class="n">redirect_to</span> <span class="n">user</span>
<span class="k">else</span>
<span class="n">flash</span><span class="p">.</span><span class="nf">now</span><span class="p">[</span><span class="ss">:danger</span><span class="p">]</span> <span class="o">=</span> <span class="s1">'Invalid email/password combination'</span>
<span class="n">render</span> <span class="s1">'new'</span>
<span class="k">end</span>
<span class="k">end</span>
<span class="k">def</span> <span class="nf">destroy</span>
<span class="n">log_out</span> <span class="k">if</span> <span class="n">logged_in?</span>
<span class="n">redirect_to</span> <span class="n">root_url</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>至此,我们的登录系统完成了。你可以在浏览器中勾选或不勾选“记住我”确认一下。</p>
<div data-type="sidebar" id="aside-ternary-operator" class="sidebar">
<h5><span class="title-label">旁注 9.2</span>:世界上有 10 种人</h5>
<p>有一个老笑话,说世界上有 10 种人,懂二进制的人和不懂二进制的人。(这里的 10,在二进制中是 2。)同理,我们可以说,世界上有 10 种人,一种人喜欢三元运算符,一种人不喜欢,还有一种人不知道三元运算符是什么。(如果你碰巧是第三种人,马上就不是了。)</p>
<p>编程一段时间之后,你会发现,最常使用的控制流程之一是下面这种:</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="k">if</span> <span class="n">boolean?</span>
<span class="n">do_one_thing</span>
<span class="k">else</span>
<span class="n">do_something_else</span>
<span class="k">end</span>
</code></pre></div>
</div>
<p>Ruby 和其他很多语言一样(包括 C/C++、Perl、PHP 和 Java),提供了一种更为简单的表达式来替代这种流程控制结构——三元运算符(之所以这么叫,是因为三元运算符包括三部分):</p>
<div data-type="listing">
<div class="highlight language-ruby"><pre><code><span class="n">boolean?</span> <span class="p">?</span> <span class="n">do_one_thing</span> <span class="p">:</span> <span class="n">do_something_else</span>
</code></pre></div>
</div>
<p>三元运算符甚至还可以用来替代赋值操作,所以</p>
<div data-type="listing">