Class: Admin::SessionsController

Inherits:
ActionController::Base
  • Object
show all
Defined in:
lib/generators/open_loam/install/templates/admin/sessions_controller.rb

Overview

Email + password login for the admin, then (if the user has MFA) a TOTP challenge, then the tenant pick. A OpenLoam user can belong to several tenants, so: authenticate → second factor → choose a tenant where they hold a membership. The second factor runs BEFORE any tenant is chosen, which is why the MFA secret is keyed to the user, not a tenant (OpenLoam::MfaCredential).

Not a BaseController subclass: everything there assumes an established OpenLoam::Current, which is exactly what this controller is trying to build.

Instance Method Summary collapse

Instance Method Details

#create ⇒ Object



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/generators/open_loam/install/templates/admin/sessions_controller.rb', line 20

def create
  email = params[:email]

  # Rate-limit BEFORE touching the password (OpenLoam::AuthThrottle). Throttle by
  # the submitted identifier whether or not the account exists, and give the
  # SAME generic locked response either way — so a lockout can't become an
  # account-existence oracle.
  if OpenLoam::AuthThrottle.locked?(email)
    @error = throttle_message(email)
    return render :new, status: :too_many_requests
  end

  user = User.authenticate_by(email: email, password: params[:password])

  if user.nil?
    OpenLoam::AuthThrottle.record_failure(email, kind: "password", ip: request.remote_ip)
    # One message for both cases on purpose: saying which half was wrong
    # tells an attacker which emails exist.
    @error = "Wrong email or password."
    return render :new, status: :unauthorized
  end

  # PASSWORD only — clearing every kind here would reset the TOTP counter.
  OpenLoam::AuthThrottle.clear(email, kind: "password")
  reset_session # a fresh session id at login: no fixation
  session[:user_id] = user.id

  # Password is only the first factor when MFA is active — hold the login in
  # a "pending" state (no tenant, no access) until the code checks out.
  if OpenLoam::MfaCredential.active_for(user)
    session[:mfa_pending] = true
    redirect_to mfa_challenge_admin_session_path
  else
    complete_authentication(user)
  end
end

#destroy ⇒ Object



173
174
175
176
177
# File 'lib/generators/open_loam/install/templates/admin/sessions_controller.rb', line 173

def destroy
  reset_session
  OpenLoam::Current.reset
  redirect_to new_admin_session_path
end

#mfa_challenge ⇒ Object

The TOTP challenge screen (only reachable mid-login, password already given).



58
59
60
# File 'lib/generators/open_loam/install/templates/admin/sessions_controller.rb', line 58

def mfa_challenge
  @user = mfa_challenge_user or return redirect_to new_admin_session_path
end

#mfa_verify ⇒ Object



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/generators/open_loam/install/templates/admin/sessions_controller.rb', line 62

def mfa_verify
  user = mfa_challenge_user or return redirect_to new_admin_session_path

  # A 6-digit TOTP (with drift, several codes valid) is the prime brute-force
  # target — lock it out on the user's identifier.
  if OpenLoam::AuthThrottle.locked?(user.email)
    @user = user
    @error = throttle_message(user.email)
    return render :mfa_challenge, status: :too_many_requests
  end

  credential = OpenLoam::MfaCredential.active_for(user)

  if credential.verify_totp(params[:code]) || credential.consume_recovery_code(params[:code])
    OpenLoam::AuthThrottle.clear(user.email, kind: "totp")
    session.delete(:mfa_pending)
    complete_authentication(user)
  else
    OpenLoam::AuthThrottle.record_failure(user.email, kind: "totp", ip: request.remote_ip)
    # One generic error — never reveal whether a code was close or a
    # recovery code was already spent.
    @user = user
    @error = "That code is not valid."
    render :mfa_challenge, status: :unauthorized
  end
end

#new ⇒ Object



13
14
15
16
17
18
# File 'lib/generators/open_loam/install/templates/admin/sessions_controller.rb', line 13

def new
  return redirect_to mfa_challenge_admin_session_path if session[:mfa_pending]

  @user = authenticated_user
  @tenants = @user ? OpenLoam::Membership.tenants_for(@user) : OpenLoam::Tenant.none
end

#select_tenant ⇒ Object

Step two (or three): the tenant picker posts here.



158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/generators/open_loam/install/templates/admin/sessions_controller.rb', line 158

def select_tenant
  return redirect_to mfa_challenge_admin_session_path if session[:mfa_pending]

  user = authenticated_user
  return redirect_to new_admin_session_path if user.nil?

  tenant = OpenLoam::Membership.tenants_for(user).find_by(id: params[:tenant_id])

  # A tenant the user has no membership in is simply not in that list, so
  # picking one is impossible rather than merely forbidden.
  return redirect_to new_admin_session_path, alert: "You are not a member of that tenant." if tenant.nil?

  enter(user, tenant)
end

#sso_callback ⇒ Object

The IdP redirects back here with a code + state. GET, so Rails' form CSRF does not apply — the state check IS the CSRF defense. Verify it first, then exchange the code and provision inside the provider's tenant.



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/generators/open_loam/install/templates/admin/sessions_controller.rb', line 117

def sso_callback
  state = session.delete(:sso_state)
  provider_id = session.delete(:sso_provider_id)
  tenant_id = session.delete(:sso_tenant_id)

  if state.blank? || !ActiveSupport::SecurityUtils.secure_compare(state, params[:state].to_s)
    return redirect_to new_admin_session_path, alert: "SSO sign-in could not be verified. Please try again."
  end

  tenant = OpenLoam::Tenant.find_by(id: tenant_id)
  provider = tenant && OpenLoam.as_tenant(tenant) { OpenLoam::SsoProvider.find_by(id: provider_id) }
  if provider.nil? || !provider.active?
    return redirect_to new_admin_session_path, alert: "That SSO provider is no longer available."
  end

  user = OpenLoam.as_tenant(tenant) do
    claims = OpenLoam::Sso.build(provider, redirect_uri: sso_callback_admin_session_url).exchange(code: params[:code])
    OpenLoam::Sso.provision(provider, claims)
  end

  reset_session
  session[:user_id] = user.id
  session[:sso_tenant_id] = tenant.id  # land in the IdP's tenant after any MFA

  # SSO establishes primary auth; if the user also runs app-side MFA, still
  # require the second factor (the safe choice — SSO does not waive it).
  if OpenLoam::MfaCredential.active_for(user)
    session[:mfa_pending] = true
    redirect_to mfa_challenge_admin_session_path
  else
    complete_authentication(user)
  end
rescue OpenLoam::Sso::Error
  # An unverified email, a domain the provider does not own, or any other
  # provisioning refusal: no session, nothing linked, one generic message.
  reset_session
  redirect_to new_admin_session_path,
              alert: "We couldn't sign you in with SSO. Please contact your administrator."
end

#sso_start ⇒ Object

SSO entry: the user types an email, home-realm discovery finds the tenant's IdP by domain, and we redirect to it. No provider for that domain? Fall back to password login. The state is our CSRF token for the round-trip.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/generators/open_loam/install/templates/admin/sessions_controller.rb', line 92

def sso_start
  email = params[:email].to_s
  provider = OpenLoam::Sso.provider_for(email: email)

  if provider.nil?
    return redirect_to new_admin_session_path(email: email),
                       alert: "No SSO provider is configured for that email domain — sign in with your password."
  end

  state = SecureRandom.urlsafe_base64(24)
  reset_session
  session[:sso_state] = state
  session[:sso_provider_id] = provider.id
  session[:sso_tenant_id] = provider.tenant_id

  url = OpenLoam.as_tenant(provider.tenant) do
    OpenLoam::Sso.build(provider, redirect_uri: sso_callback_admin_session_url)
             .authorization_url(state: state, login_hint: email)
  end
  redirect_to url, allow_other_host: true
end