你的系统中最薄弱的地方之一很容易是终端用户的证书。我们很容易忘记,大多数人没有启用2FA,没有使用密码管理器,甚至没有一个合理长度的密码。
与其规定密码要有一定的长度和3个特殊字符,不如完全取消对密码的需求,这样会更好。
在本教程中,我将向你展示我是如何使用一次性密码和电子邮件在Rails中实现无密码账户的。
它是如何工作的?
登录的基本流程如下。
- 用户输入他们的电子邮件地址
- 一次性密码通过电子邮件发送给他们
- 在浏览器中输入OTP就可以登录了。
对于注册,这略有不同。当你提交一个没有账户的电子邮件时,页面将重新加载,并要求你提供名字和姓氏,然后提交表格将创建你的账户,并向你发送一个OTP来登录。
优点
不再担心密码安全问题
用户不可能有不安全或不牢固的密码,因为他们一开始就没有密码!也不需要重设密码、更改密码和所有与之相关的通知和电子邮件。
电子邮件被验证为标准
不需要验证您的电子邮件地址,如果用户得到代码并输入,他们的电子邮件就被验证了。
对于Nine
来说,我们希望在创建订单并将其发送到Stripe结账之前确保潜在客户的电子邮件被验证。
注册流程更快
由于不需要填写密码和密码确认,账户创建表单可以大幅简化。这是更好的用户体验,特别是在商务方面。
为什么不使用第三方服务?
现在有很多第三方认证服务,magic.link是我见过的最受关注的一个。
就我个人的经验而言,我从来不喜欢在我的系统中如此关键的部分依赖第三方。
我知道,我知道,推出你自己的认证是一个糟糕的想法,如果我在建立一个密码系统,我会使用像Devise这样的库。如果有人对我的方法有任何安全方面的顾虑或想法,请回复并告诉我,我很愿意进一步讨论这个问题。
建立它
对于那些感兴趣的人,我将向你展示所有相关的代码,如果你有进一步的问题,请在评论中提问。
依赖性
为了依靠安全的OTP,我们需要在我们的Gemfile中加入几个依赖项。
# One time passwords
gem "rotp"
gem "base32"
app/models/user.rb
你的用户至少应该有以下数据库字段。
create_table :users do |t|
t.string "email", null: false
t.string "first_name", null: false
t.string "last_name", null: false
t.string "auth_secret", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index(:users, :email, unique: true)
接下来,我们需要在用户模型中添加一些方法来生成和验证OTP。
class User < ApplicationRecord
before_create :generate_auth_secret
validates :email, email: true, presence: true
validates :first_name, :last_name, presence: true
def self.generate_auth_salt
ROTP::Base32.random(16)
end
def auth_code(salt)
totp(salt).now
end
def valid_auth_code?(salt, code)
# 5mins validity
totp(salt).verify(code, drift_behind: 300).present?
end
private
# This is used as a secret for this user to
# generate their OTPs, keep it private.
def generate_auth_secret
self.auth_secret = ROTP::Base32.random(16)
end
def totp(salt)
ROTP::TOTP.new(auth_secret + salt, issuer: "YourAppName")
end
end
注意盐被存储在一个cookie中,并确保用户只能从他们请求登录的同一个网络浏览器中登录。这意味着,如果有人看了他们的肩膀,得到了他们的认证码,他们就不能在不同的网络浏览器上登录。
UserLogin服务
这个服务处理处理请求代码和验证代码正确的业务逻辑,它将保持我们控制器的整洁。
module UserLogin
module_function
# Called when a user first types their email address
# requesting to login or sign up.
def start_auth(params)
# Generate the salt for this login, it will later
# be stored in rails session.
salt = User.generate_auth_salt
user = User.find_by(email: params.fetch(:email).downcase.strip)
if user.nil?
# User is registering a new account
user = User.create!(params)
end
# Email the user their 6 digit code
AuthMailer.auth_code(user, user.auth_code(salt)).deliver_now
salt
end
# Called to check the code the user types
# in and make sure it’s valid.
def verify(email, auth_code, salt)
user = User.find_by(email: email)
if user.blank?
return UserLoginResponse.new(
"Oh dear, we could not find an account using that email.
Contact support@nine.shopping if this issue persists."
)
end
unless user.valid_auth_code?(salt, auth_code)
return UserLoginResponse.new("That code’s not right, better luck next time 😬")
end
UserLoginResponse.new(nil, user)
end
UserLoginResponse = Struct.new(:error, :user)
end
控制器和路由
首先我们需要一个Authenticatable关注点,它将提供像current_user 和user_signed_in? 这样的方法。你还需要在你的application_controller.rb 文件内include Authenticatable 。
# app/controllers/concerns/authenticatable.rb
module Authenticatable
extend ActiveSupport::Concern
def authenticate_user!
redirect_to auth_path unless current_user
end
def user_signed_in?
current_user.present?
end
def current_user
@current_user ||= lookup_user_by_cookie
end
def lookup_user_by_cookie
User.find(session[:user_id]) if session[:user_id]
end
end
在你的config/routes.rb 文件中添加以下内容。
resource :auth, only: %i[show create destroy], controller: :auth
resource :auth_verifications, only: %i[show create]
我们需要两个控制器来完成这个工作,AuthController处理请求认证和注销,而AuthVerificationsController处理检查OTP是否正确。
# app/controllers/auth_controller.rb
class AuthController < ApplicationController
skip_before_action :authenticate_user!, except: :destroy
def show; end
def create
session[:email] = params[:email]
session[:salt] = UserLogin.start_auth(params.permit(:email, :first_name, :last_name))
redirect_to auth_verifications_path
rescue ActiveRecord::RecordInvalid
# If the user creations fails (usually when first and last name are empty)
# we reload the form, and also display the first and last name fields.
@display_name_fields = true
render :show
end
def destroy
session.delete(:user_id)
redirect_to auth_path, notice: "You are signed out"
end
end
# app/controllers/auth_verifications_controller.rb
class AuthVerificationsController < ApplicationController
skip_before_action :authenticate_user!
def show
@email = session[:email]
render "auth/verify"
end
def create
@email = session[:email]
resp = UserLogin.verify(@email, params[:auth_code], session[:salt])
if resp.error
flash[:error] = resp.error
render "auth/verify"
else
session.delete(:email)
session.delete(:salt)
session[:user_id] = resp.user.id
redirect_to root_path, notice: "You are now signed in"
end
end
end
视图
在这些视图中,我使用了tailwind的CSS,你可以随心所欲地设计它们。
<%# app/views/auth/show.html.erb %>
<p class="text-2xl text-gray-900 font-medium mb-3">
What’s your email?
</p>
<%= form_with(url: auth_path, html: { data: { turbo: false } }) do |f| %>
<%= f.email_field :email, value: params[:email], placeholder: "you@email.com", class: "w-full rounded-md border-gray-300" %>
<% if @display_name_fields %>
<%= f.text_field :first_name, placeholder: "First name", class: "mt-3 w-full rounded-md border-gray-300" %>
<%= f.text_field :last_name, placeholder: "Last name", class: "mt-3 w-full rounded-md border-gray-300" %>
<% end %>
<%= f.submit "Continue", class: "w-full cursor-pointer flex relative justify-center items-center py-2 px-4 rounded-md font-medium leading-6 focus:outline-none transition-colors duration-150 ease-in-out border border-transparent text-white bg-pink-600 hover:bg-pink-500 focus:border-pink-300 focus:shadow-outline-gray mt-3" %>
<div class="mt-3 text-center text-gray-600 text-sm">By continuing you agree to our <a href="https://nine.shopping/terms" target="_blank" rel="noopener noreferrer" class="underline text-pink-500">Terms of Use</a></div>
<% end %>
<%# app/views/auth/verify.html.erb %>
<div class="leading-relaxed text-lg text-gray-600">
We just emailed you a six digit code, please enter it in the box below.
</div>
<%= form_with(url: auth_verifications_path, html: { class: "mt-6" }) do |f| %>
<%= f.label :email, class: "flex items-center justify-between text-sm font-medium text-gray-700 mb-1" do %>
Email
<%= link_to "Change", auth_path, class: "text-gray-500 underline font-normal" %>
<% end %>
<%= f.email_field :email, value: @email, placeholder: "you@email.com", class: "w-full rounded-md border-gray-300 bg-gray-100", disabled: true %>
<%= f.label :email, class: "flex items-center justify-between text-sm font-medium text-gray-700 mb-1 mt-3" do %>
Auth code
<%= link_to "Re-send code", auth_path(email: @email), method: :post, class: "text-gray-500 underline font-normal" %>
<% end %>
<%= f.text_field :auth_code, class: "w-full rounded-md border-gray-300 text-2xl tracking-widest text-center", maxlength: 6 %>
<%= f.submit "Continue to your account", class: "w-full cursor-pointer flex relative justify-center items-center py-2 px-4 rounded-md font-medium leading-6 focus:outline-none transition-colors duration-150 ease-in-out border border-transparent text-white bg-pink-600 hover:bg-pink-500 focus:border-pink-300 focus:shadow-outline-gray mt-3" %>
<% end %>
邮件
拼图的最后一块是连接邮件发送器来发送你的OTP。
class AuthMailer < ApplicationMailer
def auth_code(user, auth_code)
@user = user
@auth_code = auth_code
mail to: @user.email, subject: "Hey #{@user.first_name}, use this auth code to sign in"
end
end
<h1>Hey <%= @user.first_name %>,</h1>
<p>Use the six digit code below to continue signing in to your account (this will expire in 5 minutes).</p>
<table class="attributes" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="attributes_content">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="attributes_item">
<span style="display: block; font-size: 35px; font-weight: bold; letter-spacing: 10px; text-align: center;"><%= @auth_code %></span>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>If you didn't request this code you can safely ignore this email.</p>

