mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 05:11:05 +00:00
* feat: add safe admin user removal * fix: address user removal review findings * fix: close remaining user removal review gaps * fix: handle deleted users during session creation * fix: fail closed when session creation fails * fix: reject token issuance for inactive users
542 lines
19 KiB
Ruby
542 lines
19 KiB
Ruby
class SessionsController < ApplicationController
|
|
extend SslConfigurable
|
|
|
|
before_action :set_session, only: :destroy
|
|
skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start desktop_sso_start desktop_exchange]
|
|
# The desktop exchange is a cross-context POST from the app's webview that
|
|
# can't carry a CSRF token; the single-use, PKCE-bound one-time code is the
|
|
# protection instead (same model as the OAuth callback).
|
|
skip_forgery_protection only: :desktop_exchange
|
|
|
|
layout "auth"
|
|
|
|
# Handle GET /sessions (usually from browser back button)
|
|
def index
|
|
redirect_to new_session_path
|
|
end
|
|
|
|
def new
|
|
store_pending_invitation_if_valid
|
|
# Clear any stale mobile/desktop SSO session flag from an abandoned flow
|
|
session.delete(:mobile_sso)
|
|
session.delete(:desktop_sso)
|
|
|
|
begin
|
|
demo = Rails.application.config_for(:demo)
|
|
@prefill_demo_credentials = demo_host_match?(demo)
|
|
if @prefill_demo_credentials
|
|
@email = params[:email].presence || demo["email"]
|
|
@password = params[:password].presence || demo["password"]
|
|
else
|
|
@email = params[:email]
|
|
@password = params[:password]
|
|
end
|
|
rescue RuntimeError, Errno::ENOENT, Psych::SyntaxError
|
|
# Demo config file missing or malformed - disable demo credential prefilling
|
|
@prefill_demo_credentials = false
|
|
@email = params[:email]
|
|
@password = params[:password]
|
|
end
|
|
end
|
|
|
|
def create
|
|
# Clear any stale mobile SSO session flag from an abandoned mobile flow
|
|
session.delete(:mobile_sso)
|
|
|
|
user = nil
|
|
|
|
if AuthConfig.local_login_enabled?
|
|
user = User.authenticate_by(email: params[:email], password: params[:password])
|
|
else
|
|
# Local login is disabled. Only allow attempts when an emergency super-admin
|
|
# override is enabled and the email belongs to a super-admin.
|
|
if AuthConfig.local_admin_override_enabled?
|
|
candidate = User.find_by(email: params[:email])
|
|
unless candidate&.super_admin?
|
|
redirect_to new_session_path, alert: t("sessions.create.local_login_disabled")
|
|
return
|
|
end
|
|
|
|
user = User.authenticate_by(email: params[:email], password: params[:password])
|
|
else
|
|
redirect_to new_session_path, alert: t("sessions.create.local_login_disabled")
|
|
return
|
|
end
|
|
end
|
|
|
|
if user
|
|
if user.otp_required?
|
|
log_super_admin_override_login(user)
|
|
session[:mfa_user_id] = user.id
|
|
redirect_to verify_mfa_path
|
|
else
|
|
log_super_admin_override_login(user)
|
|
@session = create_session_for(user)
|
|
unless @session
|
|
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
|
|
return
|
|
end
|
|
flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user)
|
|
redirect_to root_path
|
|
end
|
|
else
|
|
flash.now[:alert] = t(".invalid_credentials")
|
|
render :new, status: :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def destroy
|
|
user = Current.user
|
|
id_token = session[:id_token_hint]
|
|
login_provider = session[:sso_login_provider]
|
|
|
|
# Find the identity for the provider used during login, with fallback to first if session data lost
|
|
oidc_identity = if login_provider.present?
|
|
user.oidc_identities.find_by(provider: login_provider)
|
|
else
|
|
user.oidc_identities.first
|
|
end
|
|
|
|
# Destroy local session
|
|
@session.destroy
|
|
session.delete(:id_token_hint)
|
|
session.delete(:sso_login_provider)
|
|
|
|
# Check if we should redirect to IdP for federated logout
|
|
if oidc_identity && id_token.present?
|
|
idp_logout_url = build_idp_logout_url(oidc_identity, id_token)
|
|
|
|
if idp_logout_url
|
|
SsoAuditLog.log_logout_idp!(user: user, provider: oidc_identity.provider, request: request)
|
|
redirect_to idp_logout_url, allow_other_host: true
|
|
return
|
|
end
|
|
end
|
|
|
|
# Standard local logout
|
|
SsoAuditLog.log_logout!(user: user, request: request)
|
|
redirect_to new_session_path, notice: t(".logout_successful")
|
|
end
|
|
|
|
# Handle redirect back from IdP after federated logout
|
|
def post_logout
|
|
redirect_to new_session_path, notice: t(".logout_successful")
|
|
end
|
|
|
|
def mobile_sso_start
|
|
provider = params[:provider].to_s
|
|
configured_providers = Rails.configuration.x.auth.sso_providers.map { |p| p[:name].to_s }
|
|
|
|
unless configured_providers.include?(provider)
|
|
mobile_sso_redirect(error: "invalid_provider", message: "SSO provider not configured")
|
|
return
|
|
end
|
|
|
|
device_params = params.permit(:device_id, :device_name, :device_type, :os_version, :app_version)
|
|
unless device_params[:device_id].present? && device_params[:device_name].present? && device_params[:device_type].present?
|
|
mobile_sso_redirect(error: "missing_device_info", message: "Device information is required")
|
|
return
|
|
end
|
|
|
|
session[:mobile_sso] = {
|
|
device_id: device_params[:device_id],
|
|
device_name: device_params[:device_name],
|
|
device_type: device_params[:device_type],
|
|
os_version: device_params[:os_version],
|
|
app_version: device_params[:app_version]
|
|
}
|
|
|
|
# Render auto-submitting form to POST to OmniAuth (required by omniauth-rails_csrf_protection)
|
|
@provider = provider
|
|
render layout: false
|
|
end
|
|
|
|
# Entry point for desktop-app SSO, opened in the system browser so passkeys
|
|
# work. Stashes the desktop PKCE challenge, then hands off to OmniAuth exactly
|
|
# like the mobile flow (reusing its auto-submitting form).
|
|
def desktop_sso_start
|
|
provider = params[:provider].to_s
|
|
configured_providers = Rails.configuration.x.auth.sso_providers.map { |p| p[:name].to_s }
|
|
|
|
unless configured_providers.include?(provider)
|
|
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
|
|
return
|
|
end
|
|
|
|
code_challenge = params[:code_challenge].to_s
|
|
# Require a well-formed PKCE (S256) challenge — a 43-char base64url SHA-256
|
|
# digest — so the one-time code returned via the custom URL scheme can only
|
|
# be redeemed by the app instance that started the flow (it alone holds the
|
|
# verifier). Validate the format before copying it into session state.
|
|
unless code_challenge.match?(/\A[A-Za-z0-9_-]{43}\z/)
|
|
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
|
|
return
|
|
end
|
|
|
|
session[:desktop_sso] = { code_challenge: code_challenge }
|
|
@provider = provider
|
|
render :mobile_sso_start, layout: false
|
|
end
|
|
|
|
# Exchanges the single-use, PKCE-bound code (delivered to the desktop app via
|
|
# sure://sso/callback) for a real web session in the app's own webview.
|
|
def desktop_exchange
|
|
code = params[:code].to_s
|
|
code_verifier = params[:code_verifier].to_s
|
|
|
|
cache_key = "desktop_sso:#{code}"
|
|
data = Rails.cache.read(cache_key)
|
|
# Atomically claim the code: only the request whose delete actually removes
|
|
# the entry may proceed, so two concurrent exchanges can't both succeed
|
|
# (delete returns false for the loser). Redis/MemoryStore both report this.
|
|
claimed = Rails.cache.delete(cache_key)
|
|
|
|
if code.blank? || code_verifier.blank? || data.blank? || !claimed
|
|
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
|
|
return
|
|
end
|
|
|
|
data = data.with_indifferent_access
|
|
expected_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
|
|
|
unless ActiveSupport::SecurityUtils.secure_compare(expected_challenge, data[:code_challenge].to_s)
|
|
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
|
|
return
|
|
end
|
|
|
|
user = User.find_by(id: data[:user_id])
|
|
# This code is minted during the OIDC callback and redeemed up to two minutes
|
|
# later, so an administrator can permanently remove the user inside that
|
|
# window. User#revoke_all_credentials! cannot reach a session that does not
|
|
# exist yet, and this path never re-consults the SSO identity (the code
|
|
# carries only a user id), so re-check the account here before minting one.
|
|
unless user&.active?
|
|
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
|
|
return
|
|
end
|
|
|
|
if user.otp_required?
|
|
session[:mfa_user_id] = user.id
|
|
redirect_to verify_mfa_path
|
|
else
|
|
@session = create_session_for(user)
|
|
unless @session
|
|
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
|
|
return
|
|
end
|
|
flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user)
|
|
redirect_to root_path
|
|
end
|
|
end
|
|
|
|
def openid_connect
|
|
auth = request.env["omniauth.auth"]
|
|
|
|
# Nil safety: ensure auth and required fields are present
|
|
unless auth&.provider && auth&.uid
|
|
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
|
|
return
|
|
end
|
|
|
|
if SsoIdentityBlock.blocked?(provider: auth.provider, uid: auth.uid)
|
|
reject_removed_sso_identity(auth.provider)
|
|
return
|
|
end
|
|
|
|
# Security fix: Look up by provider + uid, not just email
|
|
oidc_identity = OidcIdentity.find_by(provider: auth.provider, uid: auth.uid)
|
|
|
|
if oidc_identity
|
|
# Existing OIDC identity found - authenticate the user
|
|
user = oidc_identity.user
|
|
oidc_identity.record_authentication!
|
|
oidc_identity.sync_user_attributes!(auth)
|
|
|
|
# Log successful SSO login
|
|
SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request)
|
|
|
|
# Mobile SSO: issue Doorkeeper tokens and redirect to app
|
|
if session[:mobile_sso].present?
|
|
if user.otp_required?
|
|
session.delete(:mobile_sso)
|
|
mobile_sso_redirect(error: "mfa_not_supported", message: "MFA users should sign in with email and password")
|
|
else
|
|
handle_mobile_sso_callback(user)
|
|
end
|
|
return
|
|
end
|
|
|
|
# Desktop SSO: hand a single-use, PKCE-bound code back to the desktop app,
|
|
# which exchanges it for a normal web session. MFA is enforced later, at
|
|
# exchange time (the desktop webview can complete MFA), so it is supported.
|
|
if session[:desktop_sso].present?
|
|
handle_desktop_sso_callback(user)
|
|
return
|
|
end
|
|
|
|
# Store id_token and provider for RP-initiated logout
|
|
session[:id_token_hint] = auth.credentials&.id_token if auth.credentials&.id_token
|
|
session[:sso_login_provider] = auth.provider
|
|
|
|
# MFA check: If user has MFA enabled, require verification
|
|
if user.otp_required?
|
|
session[:mfa_user_id] = user.id
|
|
redirect_to verify_mfa_path
|
|
else
|
|
@session = create_session_for(user)
|
|
unless @session
|
|
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
|
|
return
|
|
end
|
|
flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user)
|
|
redirect_to root_path
|
|
end
|
|
else
|
|
# Mobile SSO with no linked identity - cache pending auth and redirect
|
|
# back to the app with a linking code so the user can link or create an account
|
|
if session[:mobile_sso].present?
|
|
handle_mobile_sso_onboarding(auth)
|
|
return
|
|
end
|
|
|
|
# Desktop SSO with no linked identity: send the app back to the login
|
|
# screen with an error. Linking/JIT creation still happens through the
|
|
# normal web flow; the desktop handoff only resumes already-linked users.
|
|
if session[:desktop_sso].present?
|
|
session.delete(:desktop_sso)
|
|
redirect_to "sure://sso/callback?error=account_not_linked", allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# No existing OIDC identity - need to link to account
|
|
# Store auth data in session and redirect to linking page
|
|
session[:pending_oidc_auth] = {
|
|
provider: auth.provider,
|
|
uid: auth.uid,
|
|
email: auth.info&.email,
|
|
name: auth.info&.name,
|
|
first_name: auth.info&.first_name,
|
|
last_name: auth.info&.last_name
|
|
}
|
|
redirect_to link_oidc_account_path
|
|
end
|
|
end
|
|
|
|
def failure
|
|
# Sanitize reason to known values only
|
|
known_reasons = %w[sso_provider_unavailable sso_invalid_response sso_failed]
|
|
sanitized_reason = known_reasons.include?(params[:message]) ? params[:message] : "sso_failed"
|
|
|
|
# Log failed SSO attempt
|
|
SsoAuditLog.log_login_failed!(
|
|
provider: params[:strategy],
|
|
request: request,
|
|
reason: sanitized_reason
|
|
)
|
|
|
|
# Mobile SSO: redirect back to the app with error instead of web login page
|
|
if session[:mobile_sso].present?
|
|
session.delete(:mobile_sso)
|
|
mobile_sso_redirect(error: sanitized_reason, message: t("sessions.failure.sso_failed"))
|
|
return
|
|
end
|
|
|
|
# Desktop SSO: send the error back to the app via the custom scheme so it
|
|
# stops waiting, instead of stranding the flow on the web login page.
|
|
if session[:desktop_sso].present?
|
|
session.delete(:desktop_sso)
|
|
redirect_to "sure://sso/callback?error=#{sanitized_reason}", allow_other_host: true
|
|
return
|
|
end
|
|
|
|
message = case sanitized_reason
|
|
when "sso_provider_unavailable"
|
|
t("sessions.failure.sso_provider_unavailable")
|
|
when "sso_invalid_response"
|
|
t("sessions.failure.sso_invalid_response")
|
|
else
|
|
t("sessions.failure.sso_failed")
|
|
end
|
|
|
|
redirect_to new_session_path, alert: message
|
|
end
|
|
|
|
private
|
|
def reject_removed_sso_identity(provider)
|
|
SsoAuditLog.log_login_failed!(
|
|
provider: provider,
|
|
request: request,
|
|
reason: "removed_identity"
|
|
)
|
|
|
|
if session.delete(:mobile_sso).present?
|
|
mobile_sso_redirect(error: "sso_failed", message: t("sessions.failure.sso_failed"))
|
|
elsif session.delete(:desktop_sso).present?
|
|
redirect_to "sure://sso/callback?error=sso_failed", allow_other_host: true
|
|
else
|
|
redirect_to new_session_path, alert: t("sessions.openid_connect.failed")
|
|
end
|
|
end
|
|
|
|
def handle_mobile_sso_callback(user)
|
|
device_info = session.delete(:mobile_sso)
|
|
|
|
unless device_info.present?
|
|
mobile_sso_redirect(error: "missing_session", message: "Mobile SSO session expired")
|
|
return
|
|
end
|
|
|
|
device = MobileDevice.upsert_device!(user, device_info.symbolize_keys)
|
|
token_response = device.issue_token!
|
|
|
|
# Store tokens behind a one-time authorization code instead of passing in URL
|
|
authorization_code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write(
|
|
"mobile_sso:#{authorization_code}",
|
|
token_response.merge(
|
|
user_id: user.id,
|
|
user_email: user.email,
|
|
user_first_name: user.first_name,
|
|
user_last_name: user.last_name,
|
|
user_ui_layout: user.ui_layout,
|
|
user_ai_enabled: user.ai_enabled?
|
|
),
|
|
expires_in: 5.minutes
|
|
)
|
|
|
|
mobile_sso_redirect(code: authorization_code)
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
Rails.logger.warn("[Mobile SSO] Device save failed: #{e.record.errors.full_messages.join(', ')}")
|
|
mobile_sso_redirect(error: "device_error", message: "Unable to register device")
|
|
end
|
|
|
|
def handle_desktop_sso_callback(user)
|
|
context = (session.delete(:desktop_sso) || {}).with_indifferent_access
|
|
code_challenge = context[:code_challenge]
|
|
|
|
if code_challenge.blank?
|
|
redirect_to "sure://sso/callback?error=missing_session", allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# One-time authorization code, bound to the PKCE challenge, exchanged by
|
|
# the desktop webview for a session. Short TTL + single-use + PKCE.
|
|
code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write(
|
|
"desktop_sso:#{code}",
|
|
{ "user_id" => user.id, "code_challenge" => code_challenge },
|
|
expires_in: 2.minutes
|
|
)
|
|
|
|
redirect_to "sure://sso/callback?code=#{code}", allow_other_host: true
|
|
end
|
|
|
|
def handle_mobile_sso_onboarding(auth)
|
|
device_info = session.delete(:mobile_sso)
|
|
email = auth.info&.email
|
|
|
|
has_pending_invitation = email.present? && Invitation.pending.exists?(email: email)
|
|
allow_creation = has_pending_invitation || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email))
|
|
|
|
linking_code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write(
|
|
"mobile_sso_link:#{linking_code}",
|
|
{
|
|
provider: auth.provider,
|
|
uid: auth.uid,
|
|
email: email,
|
|
first_name: auth.info&.first_name,
|
|
last_name: auth.info&.last_name,
|
|
name: auth.info&.name,
|
|
issuer: auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss"),
|
|
device_info: device_info,
|
|
allow_account_creation: allow_creation
|
|
},
|
|
expires_in: 10.minutes
|
|
)
|
|
|
|
mobile_sso_redirect(
|
|
status: "account_not_linked",
|
|
linking_code: linking_code,
|
|
email: email,
|
|
first_name: auth.info&.first_name,
|
|
last_name: auth.info&.last_name,
|
|
allow_account_creation: allow_creation,
|
|
has_pending_invitation: has_pending_invitation
|
|
)
|
|
end
|
|
|
|
def mobile_sso_redirect(params = {})
|
|
redirect_to "sureapp://oauth/callback?#{params.to_query}", allow_other_host: true
|
|
end
|
|
|
|
def set_session
|
|
@session = Current.user.sessions.find(params[:id])
|
|
end
|
|
|
|
def log_super_admin_override_login(user)
|
|
# Only log when local login is globally disabled but an emergency
|
|
# super-admin override is enabled.
|
|
return if AuthConfig.local_login_enabled?
|
|
return unless AuthConfig.local_admin_override_enabled?
|
|
return unless user&.super_admin?
|
|
|
|
Rails.logger.info("[AUTH] Super admin override login: user_id=#{user.id} email=#{user.email}")
|
|
end
|
|
|
|
def demo_host_match?(demo)
|
|
return false unless demo.present? && demo["hosts"].present?
|
|
|
|
demo["hosts"].include?(request.host)
|
|
end
|
|
|
|
def build_idp_logout_url(oidc_identity, id_token)
|
|
# Find the provider configuration using unified loader (supports both YAML and DB providers)
|
|
provider_config = ProviderLoader.load_providers.find do |p|
|
|
p[:name] == oidc_identity.provider
|
|
end
|
|
|
|
return nil unless provider_config
|
|
|
|
# For OIDC providers, fetch end_session_endpoint from discovery
|
|
if provider_config[:strategy] == "openid_connect" && provider_config[:issuer].present?
|
|
begin
|
|
discovery_url = discovery_url_for(provider_config[:issuer])
|
|
response = Faraday.new(ssl: self.class.faraday_ssl_options).get(discovery_url) do |req|
|
|
req.options.timeout = 5
|
|
req.options.open_timeout = 3
|
|
end
|
|
|
|
return nil unless response.success?
|
|
|
|
discovery = JSON.parse(response.body)
|
|
end_session_endpoint = discovery["end_session_endpoint"]
|
|
|
|
return nil unless end_session_endpoint.present?
|
|
|
|
# Build the logout URL with post_logout_redirect_uri
|
|
post_logout_redirect = "#{request.base_url}/auth/logout/callback"
|
|
params = {
|
|
id_token_hint: id_token,
|
|
post_logout_redirect_uri: post_logout_redirect
|
|
}
|
|
|
|
"#{end_session_endpoint}?#{params.to_query}"
|
|
rescue Faraday::Error, JSON::ParserError, StandardError => e
|
|
Rails.logger.warn("[SSO] Failed to fetch OIDC discovery for logout: #{e.message}")
|
|
nil
|
|
end
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def discovery_url_for(issuer)
|
|
if issuer.end_with?("/")
|
|
"#{issuer}.well-known/openid-configuration"
|
|
else
|
|
"#{issuer}/.well-known/openid-configuration"
|
|
end
|
|
end
|
|
end
|