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

feat(auth): Add user last login informations #3242

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions app/graphql/types/user_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class UserType < Types::BaseObject
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false

field :last_login_at, GraphQL::Types::ISO8601DateTime, null: true
field :last_login_method, Types::Users::LoginMethodEnum, null: true

def memberships
object.memberships.active.includes(:organization)
end
Expand Down
13 changes: 13 additions & 0 deletions app/graphql/types/users/login_method_enum.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Types
module Users
class LoginMethodEnum < Types::BaseEnum
graphql_name "UserLoginMethod"

User::LOGIN_METHODS.each do |type|
value type
end
end
end
end
26 changes: 20 additions & 6 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ class User < ApplicationRecord
include PaperTrailTraceable
has_secure_password

LOGIN_METHODS = [
:email,
:google,
:okta
].freeze

enum :last_login_method, LOGIN_METHODS

has_many :password_resets

has_many :memberships
Expand All @@ -19,20 +27,26 @@ class User < ApplicationRecord
has_many :subscriptions, through: :customers

validates :email, presence: true
validates :password, presence: true
validates :password, presence: true, on: :create
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to allow empty passwords on update I think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will not override password if its nil? (since the password digest should stay)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the only other solution we have, is to disable validations for the update on the touch method

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how can this validation fail on touch?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it always fail and say that the password is empty, we do not have any password field in DB thats why


def can?(permission, organization:)
memberships.find { |m| m.organization_id == organization.id }&.can?(permission)
end

def touch_last_login!(method)
update!(last_login_method: method, last_login_at: Time.current)
end
end

# == Schema Information
#
# Table name: users
#
# id :uuid not null, primary key
# email :string
# password_digest :string
# created_at :datetime not null
# updated_at :datetime not null
# id :uuid not null, primary key
# email :string
# last_login_at :datetime
# last_login_method :integer
# password_digest :string
# created_at :datetime not null
# updated_at :datetime not null
#
9 changes: 6 additions & 3 deletions app/services/auth/google_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ def login(code)
return result.single_validation_failure!(error_code: "user_does_not_exist")
end

UsersService.new.new_token(user)
result = UsersService.new.new_token(user)
user.touch_last_login!(:google)
result
rescue Google::Auth::IDTokens::SignatureError
result.single_validation_failure!(error_code: "invalid_google_token")
rescue Signet::AuthorizationError
Expand All @@ -44,7 +46,7 @@ def register_user(code, organization_name)

google_oidc = oidc_verifier(code:)

UsersService.new.register(google_oidc["email"], SecureRandom.hex, organization_name)
UsersService.new.register(google_oidc["email"], SecureRandom.hex, organization_name, method: :google)
rescue Google::Auth::IDTokens::SignatureError
result.single_validation_failure!(error_code: "invalid_google_token")
rescue Signet::AuthorizationError
Expand All @@ -68,7 +70,8 @@ def accept_invite(code, invite_token)
invite:,
email: google_oidc["email"],
token: invite_token,
password: SecureRandom.hex
password: SecureRandom.hex,
method: :google
)
rescue Google::Auth::IDTokens::SignatureError
result.single_validation_failure!(error_code: "invalid_google_token")
Expand Down
3 changes: 2 additions & 1 deletion app/services/auth/okta/accept_invite_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def call
invite: result.invite,
email: result.email,
token: invite_token,
password: SecureRandom.hex
password: SecureRandom.hex,
method: :okta
)
rescue ValidationError => e
result.single_validation_failure!(error_code: e.message)
Expand Down
4 changes: 3 additions & 1 deletion app/services/auth/okta/login_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ def call
find_or_create_user
find_or_create_membership

UsersService.new.new_token(result.user)
result.token = UsersService.new.new_token(result.user)
result.user.touch_last_login!(:okta)
result.token
rescue ValidationError => e
result.single_validation_failure!(error_code: e.message)
result
Expand Down
3 changes: 1 addition & 2 deletions app/services/invites/accept_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ def call(**args)
return result.not_found_failure!(resource: "invite") unless invite

ActiveRecord::Base.transaction do
result = UsersService.new.register_from_invite(invite, args[:password])
result = UsersService.new.register_from_invite(invite, args[:password], method: args[:method])

invite.recipient = result.membership

invite.mark_as_accepted!

result
Expand Down
15 changes: 11 additions & 4 deletions app/services/users_service.rb
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
# frozen_string_literal: true

class UsersService < BaseService
def login(email, password)
def login(email, password, method: :email)
result.user = User.find_by(email:)&.authenticate(password)

unless result.user.present? && result.user.memberships&.active&.any?
return result.single_validation_failure!(error_code: "incorrect_login_or_password")
end

result.token = generate_token if result.user
if result.user
result.token = generate_token
result.user.touch_last_login!(method)
end

# NOTE: We're tracking the first membership linked to the user.
SegmentIdentifyJob.perform_later(membership_id: "membership/#{result.user.memberships.first.id}")

result
end

def register(email, password, organization_name)
def register(email, password, organization_name, method: :email)
if ENV.fetch("LAGO_DISABLE_SIGNUP", "false") == "true"
return result.not_allowed_failure!(code: "signup_disabled")
end
Expand All @@ -42,17 +45,19 @@ def register(email, password, organization_name)
)

result.token = generate_token
result
rescue ActiveRecord::RecordInvalid => e
result.record_validation_failure!(record: e.record)
end

SegmentIdentifyJob.perform_later(membership_id: "membership/#{result.membership.id}")
track_organization_registered(result.organization, result.membership)

result.user.touch_last_login!(method)
result
end

def register_from_invite(invite, password)
def register_from_invite(invite, password, method: :email)
ActiveRecord::Base.transaction do
result.user = User.find_or_create_by!(email: invite.email) { |u| u.password = password }
result.organization = invite.organization
Expand All @@ -66,8 +71,10 @@ def register_from_invite(invite, password)
result.token = generate_token
rescue ActiveRecord::RecordInvalid => e
result.record_validation_failure!(record: e.record)
return result
end

result.user.touch_last_login!(method)
result
end

Expand Down
8 changes: 8 additions & 0 deletions db/migrate/20250224095824_add_last_login_at_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

class AddLastLoginAtToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :last_login_at, :datetime, null: true
add_column :users, :last_login_method, :integer, null: true
end
end
4 changes: 3 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions schema.graphql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion spec/services/invites/accept_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
let(:accept_args) do
{
token: invite.token,
password: "ILoveLago!"
password: "ILoveLago!",
method: :email
}
end

Expand Down