进行Rails无密码认证的教程

138 阅读3分钟

你的系统中最薄弱的地方之一很容易是终端用户的证书。我们很容易忘记,大多数人没有启用2FA,没有使用密码管理器,甚至没有一个合理长度的密码。

与其规定密码要有一定的长度和3个特殊字符,不如完全取消对密码的需求,这样会更好。

在本教程中,我将向你展示我是如何使用一次性密码和电子邮件在Rails中实现无密码账户的。

它是如何工作的?

登录的基本流程如下。

  1. 用户输入他们的电子邮件地址
  2. 一次性密码通过电子邮件发送给他们
  3. 在浏览器中输入OTP就可以登录了。

Nine passwordless-auth demo

对于注册,这略有不同。当你提交一个没有账户的电子邮件时,页面将重新加载,并要求你提供名字和姓氏,然后提交表格将创建你的账户,并向你发送一个OTP来登录。

Signup flow

优点

不再担心密码安全问题
用户不可能有不安全或不牢固的密码,因为他们一开始就没有密码!也不需要重设密码、更改密码和所有与之相关的通知和电子邮件。

电子邮件被验证为标准
不需要验证您的电子邮件地址,如果用户得到代码并输入,他们的电子邮件就被验证了。
对于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_useruser_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>