Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add registering and login with OTP through email #1068

Merged
merged 15 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,6 @@ Rails/ApplicationController:
- 'app/controllers/images_controller.rb'
- 'app/controllers/labels_controller.rb'

# Offense count: 1
# Cop supports --auto-correct.
Rails/ApplicationMailer:
Exclude:
- 'app/mailers/usergroup_mailer.rb'

# Offense count: 7
# Configuration parameters: EnforcedStyle.
# SupportedStyles: strict, flexible
Expand Down
36 changes: 36 additions & 0 deletions app/controllers/concerns/with_email_auth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

# Based on https://github.com/weg-li/weg-li/blob/master/app/controllers/sessions_controller.rb
# Original author: https://github.com/phoet

module WithEmailAuth
def email; end

def email_login
email = normalize_email(params[:email])
if email.present? && valid_looking_email?(email)
token = EmailAuthToken.generate(email)
from = Whitelabel[:email]
label_name = t("label.#{Whitelabel[:label_id]}.name")
label_link = Whitelabel[:canonical_url]
UserMailer.login_link(email, token, from, I18n.locale,
label_name, label_link).deliver_later

redirect_to root_path, notice: t('email_auth.email_sent', email:)
else
flash.now[:alert] = t('email_auth.invalid_email')

render :email, status: :unprocessable_entity
end
end

protected

def normalize_email(email)
email.to_s.strip.downcase
end

def valid_looking_email?(email)
email.match(/\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i)
end
end
5 changes: 4 additions & 1 deletion app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

class SessionsController < ApplicationController
include WithEmailAuth

def offline_login
user = User.find_by(nickname: params[:nickname])
sign_in(user)
Expand All @@ -14,7 +16,8 @@ def create
begin
authorization = Authorization.handle_authorization(current_user, request.env['omniauth.auth'])
sign_in(authorization.user)
options = { notice: t('flash.logged_in', name: current_user.name) }
user_name = current_user.missing_name? ? '' : current_user.name
options = { notice: t('flash.logged_in', name: user_name) }
rescue User::DuplicateNickname => e
options = { alert: t('flash.duplicate_nick', name: e.nickname) }
end
Expand Down
7 changes: 6 additions & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ def index; end

def show; end

def edit; end
def edit
return unless current_user.missing_name?

user.name = nil
user.errors.add(:name, :required)
end

def calendar
respond_to do |format|
Expand Down
12 changes: 11 additions & 1 deletion app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ def cache_image_path(model)
end

def login_providers
%w[twitter github google_oauth2]
%w[twitter github google_oauth2 email]
end

def icon_for_provider(provider)
return 'envelope' if provider == 'email'

provider
end

def whitelabel_stylesheet_link_tag
Expand Down Expand Up @@ -81,6 +87,10 @@ def hint(close = true)
end
end

def user_name(user)
user.display_name
end

private

def markdown_parser
Expand Down
6 changes: 3 additions & 3 deletions app/helpers/link_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

module LinkHelper
def link_to_user(user, image: false, image_class: nil)
link_to(user, title: user.name) do
image ? user_image(user, image_class:) + user.name : user.name
link_to(user, title: user_name(user)) do
image ? user_image(user, image_class:) + user_name(user) : user_name(user)
end
end

def user_image(user, image_class: nil)
image_class ||= 'small-user-image'
image_tag(cache_image_path(user), title: user.name, class: image_class)
image_tag(cache_image_path(user), title: user_name(user), class: image_class)
end

def link_to_job(job)
Expand Down
22 changes: 22 additions & 0 deletions app/lib/email_auth_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

# Based on https://github.com/weg-li/weg-li/blob/master/app/lib/token.rb
# Original author: https://github.com/phoet

class EmailAuthToken
def self.generate(
email,
expiration: 15.minutes,
secret: Rails.application.secrets.secret_key_base
)
now_seconds = Time.now.to_i
payload = { iss: email, iat: now_seconds, exp: now_seconds + expiration }
token = ::JWT.encode(payload, secret, 'HS256')
Base64.encode64(token)
end

def self.decode(string, secret: Rails.application.secrets.secret_key_base)
token = Base64.decode64(string)
JWT.decode(token, secret, true, algorithm: 'HS256').first
end
end
4 changes: 4 additions & 0 deletions app/mailers/application_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class ApplicationMailer < ActionMailer::Base
end
17 changes: 17 additions & 0 deletions app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

# Based on https://github.com/weg-li/weg-li/blob/master/app/mailers/user_mailer.rb
# Original author: https://github.com/phoet

class UserMailer < ApplicationMailer
def login_link(email, token, from, locale, label_name, label_link) # rubocop:disable Metrics/ParameterLists
@token = token
@from = from
@label_name = label_name
@label_link = label_link

I18n.with_locale(locale) do
mail from: @from, to: email, subject: t('email_auth.subject', label: label_name)
end
end
end
2 changes: 1 addition & 1 deletion app/mailers/usergroup_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

class UsergroupMailer < ActionMailer::Base
class UsergroupMailer < ApplicationMailer
def invitation_mail(event)
@event = event
options = {
Expand Down
41 changes: 40 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

class User < ApplicationRecord
class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
include Slug
extend ApiHandling
slugged_by(:nickname)
Expand Down Expand Up @@ -84,10 +84,49 @@ def handle_google_oauth2_attributes(hash)
self.image = hash['info']['image']
end

def handle_email_attributes(hash)
received_email = hash['info']['email']

self.nickname = nickname_from_email(received_email) unless nickname
self.name = name_from_email(received_email) unless name
self.image = image_from_email(received_email) unless image
self.email = received_email
end

def hash_for_email(email)
Digest::SHA256.new.hexdigest(email)
end

def nickname_from_email(email)
hash_for_email(email)
end

def hide_nickname?
nickname == nickname_from_email(email)
end

EMPTY_NAME = '********'

def name_from_email(_email)
EMPTY_NAME
end

def image_from_email(email)
"https://www.gravatar.com/avatar/#{hash_for_email(email)}"
end

def with_provider?(provider)
authorizations.map(&:provider).include?(provider)
end

def missing_name?
name == EMPTY_NAME
end

def display_name
missing_name? ? '-' : name
end

class DuplicateNickname < StandardError
attr_reader :nickname

Expand Down
8 changes: 8 additions & 0 deletions app/views/application/_hint.slim
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,11 @@
li.mb-4
span.job-toggle= link_to fa_icon("chevron-up"), '#', title: t("hint.click_to_refresh") if index == 0
.job.ml-4== job_description(job)

- if current_user&.missing_name?
= hint(false) do
.highlights
=> fa_icon("exclamation-triangle")
strong=> t("flash.update_profile_details")
- unless current_page?(edit_user_path(current_user))
= link_to t("login.edit_profile"), edit_user_path(current_user)
2 changes: 1 addition & 1 deletion app/views/application/_nav.slim
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ nav.navbar.sticky-top.navbar-expand-lg#nav
.dropdown-menu.dropdown-menu-right(aria-labelledby="loginDropdown")
- login_providers.each do |provider|
= button_to(label_auth_url(provider), class: 'dropdown-item') do
= fa_icon(provider, class: 'fa-fw', text: t("login.#{provider}_login"))
= fa_icon(icon_for_provider(provider), class: 'fa-fw', text: t("login.#{provider}_login"))


li.nav-item.dropdown.pr-4
Expand Down
9 changes: 9 additions & 0 deletions app/views/sessions/email.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
section
h3= t("email_auth.header")

.d-flex.justify-content-center
= form_tag email_login_path do |form|
legend= t('email_auth.enter_email')
- email = signed_in? ? current_user.email : params[:email]
= email_field_tag :email, email, placeholder: '[email protected]', required: true
= submit_tag t('email_auth.submit'), class: 'btn-primary'
2 changes: 1 addition & 1 deletion app/views/sessions/index.slim
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ section
- login_providers.each do |provider|
li.list-group-item
= button_to(label_auth_url(provider)) do
= fa_icon(provider, class: 'fa-fw dropdown-list-icon', text: t("login.#{provider}_login"))
= fa_icon(icon_for_provider(provider), class: 'fa-fw dropdown-list-icon', text: t("login.#{provider}_login"))
11 changes: 11 additions & 0 deletions app/views/user_mailer/login_link.text.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<%= t("email_auth.body.salute") %>,

<%= t("email_auth.body.intro", label: @label_name) %>,

<%= provider_callback_url(provider: :email, token: @token, host: @label_link) %>

<%= t("email_auth.body.final_details") %>

--
<%= @label_name %>
<%= @label_link %>
2 changes: 1 addition & 1 deletion app/views/users/edit.slim
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ section
- login_providers.each do |provider|
li
= button_to label_auth_url(provider), title: t("login.#{provider}_login"), class: "btn btn-#{existing_providers.include?(provider) ? 'disabled' : 'secondary'}", disabled: existing_providers.include?(provider) do
= fa_icon provider, class: 'fa-fw dropdown-list-icon'
= fa_icon icon_for_provider(provider), class: 'fa-fw dropdown-list-icon'
=> t("login.#{provider}_login")
- if existing_providers.include? provider
= fa_icon 'check', class: 'fa-fw dropdown-list-icon'
3 changes: 2 additions & 1 deletion app/views/users/show.slim
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
.card-body
h2.card-title
= user_image(user)
= "#{user.name} (#{user.nickname})"
= "#{user_name(user)}"
= " (#{user.nickname})" unless user.hide_nickname?
small.text-muted
span>= I18n.tw("profile.freelancer") if user.freelancer?
span>= "(#{t("profile.available")})" if user.available?
Expand Down
3 changes: 3 additions & 0 deletions config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,7 @@

# Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true

# For testing async jobs
config.active_job.queue_adapter = :test
end
3 changes: 3 additions & 0 deletions config/initializers/omniauth.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require_relative '../../lib/omni_auth/strategies/email'

OmniAuth.config.logger = Rails.logger

Rails.application.config.middleware.use OmniAuth::Builder do
Expand All @@ -14,4 +16,5 @@
env['omniauth.strategy'].options[:client_secret] = ENV["#{name}_SECRET"]
end,
}
provider :email
end
14 changes: 14 additions & 0 deletions config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,17 @@ de:
previous: "<"
next: ">"
truncate: "..."
email_auth:
header: "Mit E-Mail anmelden"
enter_email: "Bitte gib eine gültige E-Mail-Adresse ein"
submit: "Autorisieren"
email_sent: "Eine E-Mail wurde an %{email} mit Details zum Einloggen gesendet"
invalid_email: "Bitte gib eine gültige E-Mail-Adresse ein"
subject: "Anmeldung bei %{label}"
body:
salute: "Hallo"
intro: "Verwende diesen Link, um dich bei %{label} anzumelden"
good_bye: "Mit freundlichen Grüßen"
final_details: >-
Dieser Link ist nur 15 Minuten gültig.
Wenn du nicht auf den Link klicken kannst, kopiere ihn einfach und füge ihn in die Adresszeile deines Browsers ein.
15 changes: 15 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ en:
topic_added: "Added new Topic."
topic_updated: "Updated the Topic."
add_email: "Please add an email address, so that we can get in contact!"
update_profile_details: "Please, fill in your name so that we can refer to you!"
mobile:
settings: "Settings"
back: "Back"
Expand All @@ -172,3 +173,17 @@ en:
previous: "<"
next: ">"
truncate: "..."
email_auth:
header: "Login with email"
enter_email: "Please, enter your email"
submit: "Authorize"
email_sent: "An email has been sent to %{email} with details for logging in"
invalid_email: "Please enter a valid email address"
subject: "Login to %{label}"
body:
salute: "Hi"
intro: "Use this link to log into %{label}"
good_bye: "Best"
final_details: >-
This link will be valid just for 15 minutes.
If you cannot click on the link, just copy and paste it into your browser address bar.
16 changes: 15 additions & 1 deletion config/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ es:
route: "Mapa"
material: "Material"
description: "Infos"
no_location: "¡Estamos bustando sitio!"
no_location: "¡Estamos buscando sitio!"
home:
the_usergroup: "<strong>%{usergroup}</strong> es un grupo de usuarios, grupo de interés o simplemente de personas interesadas en Ruby. Contacta con nosotros en la siguiente reunión! Todo el mundo es bienvenido, incluso si no tienes mucha experiencia con Ruby."
like_to_talk: "Quieres dar una charla en el grupo, o quieres proponer un tema para una?"
Expand Down Expand Up @@ -173,3 +173,17 @@ es:
previous: "<"
next: ">"
truncate: "..."
email_auth:
header: "Acceder por Email"
enter_email: "Por favor, introduce tu email"
submit: "Autorizar"
email_sent: "Te hemos enviado un email a %{email} con los detalles para acceder"
invalid_email: "Por favor, asegúrate de que el email es correcto"
subject: "Accede a %{label}"
body:
salute: "Hola"
intro: "Usa este enlace para acceder a %{label}"
good_bye: "Saludos"
final_details: >-
Este enlace es válido sólo durante 15 minutos.
Si no puedes pulsar sobre el enlace, puedes copiarlo y pegarlo en la barra de tu navegador.
Loading
Loading