给新建用户发送密码重置邮件

用devise做用户登录,遇到的一个问题,记录下场景和leader指导出的解决方法。

场景:

添加项目成员member时,需要管理员新增用户user。

  • 管理员在新增用户页面中输入邮箱;
  • 管理员提交表单,用户的初始密码随机生成,随后给新建的用户发送一份账户激活的邮件;
  • 新增用户登录注册的邮箱,点击邮件中的链接【看似激活,实则为重置密码的链接】;
  • 用户重置密码完成,自动登录。

这里只涉及devise如何发送重置密码的邮件。

准备工作:

三个model:

对应的表结构:

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
# == Schema Information
#
# Table name: members
#
# id :integer not null, primary key
# user_id :integer
# project_id :integer
# created_at :datetime not null
# updated_at :datetime not null

# Table name: projects
#
# id :integer not null, primary key
# title :string
# created_at :datetime not null
# updated_at :datetime not null

# Table name: users
#
# id :integer not null, primary key
# email :string default(""), not null
# encrypted_password :string default(""), not null
# reset_password_token :string
# reset_password_sent_at :datetime
# remember_created_at :datetime
# sign_in_count :integer default(0), not null
# current_sign_in_at :datetime
# last_sign_in_at :datetime
# current_sign_in_ip :string
# last_sign_in_ip :string
# created_at :datetime not null
# updated_at :datetime not null

解决:

使用devise自带的sessions。在routes.rb中做如下修改:

1
2
3
4
5
### config/routes.rb

devise_for :users, path: "", controllers: {
sessions: "sessions"
}

新建一个sessionscontroller:rails g controller sessions

继承Devise::SessionsController.

1
2
3
4
### app/controllers/sessions_controller.rb

class SessionsController < Devise::SessionsController
end

如果希望root页面就是显示注册的页面,可以在routes.rb中添加如下代码:

1
2
3
4
5
### config/routes.rb

devise_scope :user do
get "/", controller: :sessions, action: :new
end

激活账户涉及confirmable模块,使用rails g devise user 创建user时,默认没有confirmable的,需要自己手动添加,请在创建user时,确保含有以下字段:

1
2
3
4
t.string   :confirmation_token
t.datetime :confirmed_at
t.datetime :confirmation_sent_at
t.string :unconfirmed_email # Only if using reconfirmable

「如果已经创建,使用rail g migration,给user添加上这些字段,同时迁移数据,给现有用户的confirmed_at赋值,不然无法登录。」

修改user.rb, 添加上模块::confirmable

1
2
3
4
5
6
### app/models/user.rb

class User < ApplicationRecord
devise :database_authenticatable, :rememberable, :validatable, :recoverable, :confirmable
......
end

同时在新增成员的表单中,允许输入用户的邮箱,如果邮箱存在,则直接添加该用户到项目成员中,如果邮箱不存在,则给该邮箱发送确认的邮件。

修改member.rb, 添加:

1
2
3
### app/models/member.rb

arr_accessor :email

这样member的new.html.erb中就可以使用f.input :email,随后在controller的create action中,通过传过来的email来判断用户是否已经存在。修改create中原来的@member.save,改成@member.submit, 在member.rb中添加submit 函数:

1
2
3
4
5
6
7
8
9
10
11
12
### app/models/member.rb

def submit(email)
password = SecureRandom.hex(4)
user = User.where(email: email).first_or_initialize(password: password, password_confirmation: password)
user.save
self.user = user
self.save
errors.empty?
end

### 这里省略了errors的delegate,如果email是无效的,需要将user的error delegate给member。

在members_controller中,修改create action:

1
2
3
4
5
6
7
8
#### app/controllers/projects/members_controller.rb

def create
if @member.submit(member_params[:email])
@user = @member.user
@user.send_activation_instructions unless @user.confirmed?
end
end

而对于send_activation_instructions,发送确认邮件的method,可以参考send_devise_notification 在 devise 中的定义:

1
2
3
4
5
6
7
8
9
10
11
### app/models/authenticatable.rb

def send_devise_notification(notification, *args)
message = devise_mailer.send(notification, self, *args)
# Remove once we move to Rails 4.2+ only.
if message.respond_to?(:deliver_now)
message.deliver_now
else
message.deliver
end
end

这里message = devise_mailer.send(notification, self, *args),devise_mailer使用send,调用参数notification传过来的方法名。

看Devise::Mailer,里面定义了5个methods, 用于发送不同类型的邮件需求:

1
2
3
4
5
6
7
8
9
10
#### app/mailers/devise/mailer.rb

## 以下是confirmation_instructions。

def confirmation_instructions(record, token, opts={})
@token = token
devise_mail(record, :confirmation_instructions, opts)
end

.......

参考这个,我们可以写出activation_instructions。

在 user.rb中,定义: send_activation_instructions。

1
2
3
4
5
6
### app/models/user.rb

def send_activation_instructions
token = set_reset_password_token ## 生成 reset_password_token,赋给token变量
send_devise_notification(:activation_instructions, token, {})
end

新增一个mailer: UserMailer,继承 devise_mailer, 定义 activation_instructions。

1
2
3
4
5
6
7
8
9
### app/mailers/user_mailer.rb

class UserMailer < Devise::Mailer

def activation_instructions(record, token, opts = {})
@token = token
devise_mail(record, :activation_instructions, opts)
end
end

修改user.rb,添加:

1
2
3
4
5
### app/models/user.rb

def devise_mailer
UserMailer
end

同时,设置 reconfirmable 为false:

1
2
3
### app/models/user.rb
devise :database_authenticatable, :rememberable, :trackable, :validatable, :recoverable, :confirmable, reconfirmable: false
......

这样在member调用submit时,user.save就不会抛出异常,要求confirm这个账户了。

在user_mailer的添加activation_instructions.html.erb 文件,文件内容可参考devise的confirmation_instructions.html.erb.

1
2
3
4
5
6
### app/views/user_mailer/activation_instructions.html.erb

<p><%= t('.greeting', recipient: @resource.name) %></p>

<p><%= t('.instruction') %></p>
<p><%= link_to t('.action'), edit_reset_password_url(reset_password_token: @token) %></p>

这里注意把confirmation_instructions.html.erb中,confirmation_url替换成 edit_reset_password_url,因为我们希望的是用户点击这个链接,跳转到密码修改的页面。

最后,把reset_password这个功能添加进来。

  • 修改routes.rb,跳过devise自带的confirmations和passwords,用reset_password来完成验证邮箱和密码重置的功能,添加:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ### config/routes.rb

    devise_for :users, path: "", controllers: {
    sessions: "sessions"
    }, skip: [ :confirmations, :passwords ]

    devise_scope :user do
    resource :reset_password
    end
  • 新建controller,继承自devise的passwordsController:

    1
    2
    3
    4
    5
    6
    7
    8
    class ResetPasswordsController < Devise::PasswordsController

    def update
    super do |resource|
    resource.confirm if resource.errors.empty? && !resource.confirmed?
    end
    end
    end

    这里重新定义了update, 调用super时,传递了一个block,如果user 没有confirmed过,同时更新密码成功,就调用confirm。在修改密码的同时,完成了用户的邮箱验证。

  • 在app/views/reset_passwords下,添加edit.html.erb,用户收到邮件时,点击“确认我的账户”时,会跳转到该页面,在该页面进行密码重置。具体页面内容,此处略。

BTW,有关邮箱的配置,可以参考rails的官方文档。在开发环境下,推荐安装 letter_opener这个gem, 就可以很轻松地查看是否正确发送了邮件了。

参考

devise

Action Mailer Basics