pax_global_header00006660000000000000000000000064151572551420014521gustar00rootroot0000000000000052 comment=6236811913899dba4a67b0d8577f0896de709c63 jeremyevans-rodauth-b53f402/000077500000000000000000000000001515725514200160445ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/.ci.gemfile000066400000000000000000000050221515725514200200460ustar00rootroot00000000000000source 'https://rubygems.org' if RUBY_VERSION < '2.0' gem 'rake', '< 10' gem 'json', '<1.8.5' gem 'chunky_png', '<1.3.13' gem 'rack-test', '< 0.7.0' else gem 'rake' gem 'json' end if RUBY_VERSION >= '3.4' # Test against rack head on highest Ruby version, # to find problems sooner. gem 'rack', :git => 'https://github.com/rack/rack' elsif RUBY_VERSION < '2.2' gem 'rack', '<2' else gem 'rack' end if RUBY_VERSION >= '2.3' gem 'argon2' elsif RUBY_VERSION >= '2.1' gem 'ffi', '<1.10' gem 'argon2', '<2.0.3' end platforms :ruby do if RUBY_VERSION < '2.4' gem "sequel-postgres-pr" else gem "pg" end if RUBY_VERSION < '2.0' gem "mysql2", '<0.5' else gem "mysql2" end if RUBY_VERSION < '2.5' gem 'sqlite3', '< 1.5' else gem 'sqlite3' end end platforms :jruby do gem 'jdbc-postgres' gem 'jdbc-mysql' gem 'jdbc-sqlite3', '<3.42' if defined?(JRUBY_VERSION) && JRUBY_VERSION.to_i < 10 gem 'racc', '<1.6' end end if RUBY_VERSION < '2.2' gem 'capybara', '<3' elsif RUBY_VERSION < '2.3' gem 'capybara', '<3.2' elsif RUBY_VERSION < '2.5' gem 'capybara', '<3.33', '>3' elsif RUBY_VERSION > '3' gem 'capybara', '>=3.40' else gem 'capybara' end if RUBY_VERSION < '2.1' gem 'addressable', '< 2.4' end if RUBY_VERSION < '2.1' gem 'nokogiri', '< 1.7' end if RUBY_VERSION < '2.0' gem 'mime-types', '< 3' end if RUBY_VERSION >= '2.3' && RUBY_VERSION < '2.4' gem 'ipaddr', '< 1.2.7' end if RUBY_VERSION < '2.1' gem 'jwt', '< 2' else gem 'jwt' end if RUBY_VERSION < '2.0' gem 'rotp', '< 4' elsif RUBY_VERSION < '2.1' gem 'rotp', '< 5' else gem 'rotp' end platforms :ruby do # cbor dependency not supported on JRuby if RUBY_VERSION > '2.5' gem 'webauthn' elsif RUBY_VERSION > '2.4' gem 'webauthn', '<2.5' elsif RUBY_VERSION > '2.3' gem 'webauthn', '<2.2.0' end end if RUBY_VERSION < '2.3' gem 'rqrcode', '<1' else gem 'rqrcode' end if RUBY_VERSION < '2.4' gem 'rubyzip', '<2' else gem 'rubyzip' end if RUBY_VERSION >= '3.1.0' gem 'net-smtp' end if RUBY_VERSION < '2.1' || (RUBY_ENGINE == 'jruby' && RUBY_VERSION < '2.5') # Avoid bigdecimal requirement gem 'sequel', '<5.72' elsif RUBY_VERSION < '2.4' gem 'sequel' gem 'bigdecimal', '<1.3' else gem 'sequel' end gem 'rack_csrf' gem 'xpath' gem 'roda' gem 'tilt' gem 'bcrypt' gem 'mail' gem 'minitest-hooks', '>= 1.1' gem 'minitest-global_expectations' if RUBY_VERSION < '2.4.0' # Until mintest 5.12.0 is fixed gem 'minitest', '5.11.3' else gem 'minitest', '>= 5.7.0' end jeremyevans-rodauth-b53f402/.github/000077500000000000000000000000001515725514200174045ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/.github/workflows/000077500000000000000000000000001515725514200214415ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/.github/workflows/ci.yml000066400000000000000000000030741515725514200225630ustar00rootroot00000000000000name: CI on: push: branches: [ master ] pull_request: branches: [ master ] permissions: contents: read jobs: tests: services: postgres: image: postgres:latest ports: ["5432:5432"] options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 env: POSTGRES_PASSWORD: postgres POSTGRES_HOST_AUTH_METHOD: md5 POSTGRES_INITDB_ARGS: --auth-host=md5 mysql: image: mysql:latest env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: rodauth_test ports: ["3306:3306"] options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 strategy: fail-fast: false matrix: os: [ubuntu-latest] ruby: [ "2.0.0", 2.1, 2.3, 2.4, 2.5, 2.6, 2.7, "3.0", 3.1, 3.2, 3.3, 3.4, "4.0", jruby-9.4, jruby-10.0 ] include: - { os: ubuntu-22.04, ruby: "1.9.3" } - { os: ubuntu-22.04, ruby: jruby-9.1 } - { os: ubuntu-22.04, ruby: jruby-9.2 } runs-on: ${{ matrix.os }} name: ${{ matrix.ruby }} env: BUNDLE_GEMFILE: .ci.gemfile steps: - uses: actions/checkout@v6 - run: sudo apt-get -yqq install libpq-dev libmysqlclient-dev libsqlite3-dev - run: sudo apt-get -yqq install libxml2-dev libxslt-dev - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - run: bundle exec rake spec_ci env: DEFAULT_DATABASE: 1 MYSQL_ROOT_PASSWORD: 1 jeremyevans-rodauth-b53f402/.gitignore000066400000000000000000000001041515725514200200270ustar00rootroot00000000000000/rodauth-*.gem /rdoc /coverage /www/public/*.html /www/public/rdoc/ jeremyevans-rodauth-b53f402/CHANGELOG000066400000000000000000000646361515725514200172750ustar00rootroot00000000000000=== 2.43.0 (2026-03-20) * Add reset_password_verifies_account feature (manfrin, jeremyevans) (#499) === 2.42.0 (2025-12-18) * Avoid mixed string/symbol keys in JSON when using the jwt_refresh feature (jeremyevans) === 2.41.0 (2025-10-08) * Clear account tokens when an account change is made (jeremyevans) === 2.40.0 (2025-08-22) * Use HTTP header instead of meta tag for otp unlock not yet available page (jeremyevans) * Add reset_password_request_for_unverified_account configuration method (jeremyevans) (#481) === 2.39.0 (2025-05-22) * Allow usage with Roda's plain_hash_response_headers plugin and Rack 3+ (jeremyevans) * Use allowed_origins instead of origin argument to WebAuthn::RelyingParty.new to avoid deprecation warning in webauthn 3.4.0+ (jeremyevans) * Change JSON.fast_generate to JSON.generate in jwt feature to avoid deprecation warning in recent json versions (jeremyevans) * Avoid exceeding 4K cookie size limit by setting an upper limit on path size when using login_return_to_requested_location? (jeremyevans) === 2.38.0 (2025-01-15) * Make verify-account-resend page work if verify_account_resend_explanatory_text calls verify_account_email_recently_sent? (jeremyevans) * Specify fixed locals for rendered templates by default, disable with use_template_fixed_locals? false (jeremyevans) * Make rodauth.has_password? method public (enescakir) (#461) * Use JWT.gem_version to check jwt gem version, for compatibility with jwt 2.10.0 (janko) (#462) * Make rodauth.*_email_recently_sent? methods public (jeremyevans) * Add Rodauth::ConfigurationError, and use it for configuration errors (janko) (#458) === 2.37.0 (2024-11-19) * Add two_factor_partially_authenticated? method for more easily determining partial authentication (janko) (#454) * Add normalize_login configuration method for normalizing submitted login parameters (jeremyevans) (#451) * Add login_confirmation_matches? configuration method to allow for case-sensitive login confirmation (jeremyevans) (#451) * Perform a case-insensitive login confirmation by default (jeremyevans) (#451) * Do not require CSRF tokens for json requests when using the json feature (janko) (#448, #449) * Make rodauth and r.rodauth call default_rodauth_name for the default configuration to use (jeremyevans) * Make clear_session not call the scope's clear_session if using JWTs for session in the jwt feature (jeremyevans) * Support webauthn_autofill? configuration method in webauthn_autofill feature for disabiling autofill on login page (janko) (#445) * Remove documentation from the gem to reduce gem size by 50% (jeremyevans) === 2.36.0 (2024-07-23) * Add webauthn_modify_email feature for emailing when a WebAuthn authenticator is added or removed (jeremyevans) * Add account_from_id method for retrieving an account using the account id and optional status id (janko) (#431) * Add otp_modify_email feature for emailing when TOTP authentication is setup or disabled (jeremyevans) * Add otp_lockout_email feature for emailing when TOTP authentication is locked out or unlocked (jeremyevans) * Add strftime_format configuration method for configuring display of Time values to users (jeremyevans) * Add otp_unlock feature for unlocking TOTP authentication after it has been locked out (jeremyevans) * Make internal_request feature work with Roda path_rewriter plugin (jeremyevans) (#425) === 2.35.0 (2024-05-28) * Handle internal_request_configuration blocks in superclasses (jeremyevans, bjeanes) * Avoid unused block warning on Ruby 3.4 (jeremyevans) * Add throw_rodauth_error method to make it possible to throw without setting a field error (jf) (#418) * Support logging out all active sessions for a loaded account that is not logged in (such as after resetting password) (enescakir) (#401) === 2.34.0 (2024-03-22) * Add remove_all_active_sessions_except_current method for removing current active session (jeremyevans) (#395) * Add remove_all_active_sessions_except_for method for removing active sessions except for given session id (jeremyevans) (#395) * Avoid overriding WebAuthn internals when using webauthn 3 (santiagorodriguez96, jeremyevans) (#398) * Support overriding webauthn_rp_id when verifying Webauthn credentials (butsjoh, jeremyevans) (#397) * Override require_login_redirect in login feature to use login_path (janko) (#396) * Do not override convert_token_id_to_integer? if the user has already configured it (janko) (#393) * Have uses_two_factor_authentication? handle case where account has been deleted (janko) (#390) * Add current_route accessor to allow easy determination of which rodauth route was requested (janko) (#381) === 2.33.0 (2023-12-21) * Expire SMS confirm code after 24 hours by default (jeremyevans) * Do not accidentally confirm SMS phone number on successful authentication of other second factor (Bertg) (#376, #377) * Return error response instead of 404 response for requests to valid pages with missing tokens (janko) (#375) * Do not override existing primary key value in the cached account when inserting a new account (janko) (#372) === 2.32.0 (2023-10-23) * Remove use of Base64 in argon2 feature (jeremyevans) * Add sms_needs_confirmation_notice_flash configuration method, supporting different flash notice for successful submission (jeremyevans) * Support *_response configuration methods for overriding common notice flash/redirect handling in many features (HoneyryderChuck, jeremyevans) (#369) * Support hmac_secret rotation in the otp feature (jeremyevans) (#365) * Support hmac_secret rotation in the email_base feature (jeremyevans) (#365) * Support hmac_secret rotation in the webauthn feature (jeremyevans) (#365) * Support hmac_secret rotation in the jwt_refresh feature (jeremyevans) (#365) * Support hmac_secret rotation in the single_session feature (jeremyevans) (#365) * Support hmac_secret rotation in the remember feature (jeremyevans) (#365) * Support hmac_secret rotation via hmac_old_secret configuration method in the active_sessions feature (jeremyevans) (#365) * Support argon2 secret rotation via argon2_old_secret configuration method and the update_password_hash feature (jeremyevans) (#365) * Support jwt secret rotation via jwt_old_secret configuration method, if using jwt 2.4+ (jeremyevans) (#365) === 2.31.0 (2023-08-22) * Make clear_session work correctly for internal requests (janko) (#359) * Support webauthn_invalid_webauthn_id_message configuration method in the webauthn_autofill feature (janko) (#356) * Support webauth features in the internal_request feature (janko) (#355) * Allow WebAuthn login to count for two factors if user verification is provided (janko) (#354) * Allow explicit use of p_cost in argon2 feature if using argon2 2.1+ (estebanz01) (#353) * Add json_response_error? configuration method to json feature, for whether response indicates an error (opya) (#340) === 2.30.0 (2023-05-22) * Make load_memory in the remember feature not raise NoMethodError if logged in when the account no longer exists (jeremyevans) (#331) * Add webauthn_autofill feature, for supporting autofill of webauthn information on the login form (janko) (#328) === 2.29.0 (2023-03-22) * Support :render=>false plugin options (davekaro) (#319) * Add remove_active_session method for removing the active session for a given session id (janko) (#317) * Remove current active session when adding new active session (janko) (#314) * Extend the remember cookie deadline once an hour by default while logged in (janko, jeremyevans) (#313) * Add account! method for returning associated account or loading account based on the session value (janko) (#309) === 2.28.0 (2023-02-22) * Skip rendering reset password request form on invalid internal request logins (janko) (#303) * Make logged_in? return false if using verify_account_grace_period feature and grace_period has expired (janko) (#300) * Make password_hash method public (janko) (#299) * Add webauthn_key_insert_hash auth method to webauthn feature to control inserts into webauthn keys table (janko) (#298) === 2.27.0 (2023-01-24) * Rename webauth_credentials_for_get to webauthn_credentials_for_get for consistency (janko) (#295) * Hide WebAuthn text inputs by default when using Bootstrap (janko) (#294) * Attempt to avoid database errors when invalid tokens are submitted (jeremyevans) * Allow button template to be overridden just as other templates can be (jeremyevans) (#280) === 2.26.1 (2022-11-08) * Fix regression in QR code generation in otp feature causing all black QR code (janko) (#279) === 2.26.0 (2022-10-21) * Raise a more informative error when using a feature requiring hmac_secret but not setting hmac_secret (janko) (#271) * Limit parameter bytesize to 1024 by default, override with max_param_bytesize configuration method (jeremyevans) * Skip displaying links for disabled routes (janko) (#269) * Do not prefix flash keys with the session key prefix (jeremyevans) (#266) * Set configuration_name correctly for internal request classes (janko) (#265) * Add argon2_secret configuration method to the argon2 feature to specify the secret/pepper used for argon2 password hashes (janko) (#264) * Use white background instead of transparent background for QR code in otp feature (jeremyevans) (#256) === 2.25.0 (2022-06-22) * Support disabling routes by passing nil/false to *_route methods (janko) (#245) === 2.24.0 (2022-05-24) * Work around implicit null byte check added in bcrypt 3.1.18 by checking password requirements before other password checks (jeremyevans) * Fix invalid HTML on pages with OTP QR codes (jeremyevans) * Add recovery_codes_available? configuration method to the recovery_codes feature (janko) (#238) * Add otp_available? configuration method to the otp feature (janko) (#238) === 2.23.0 (2022-04-22) * Don't automatically set :httponly cookie option if :http_only option is set in remember feature (jeremyevans) * Fix invalid domain check in internal_request feature when using Rack 3 (jeremyevans) * Make removing all multifactor authentication methods mark session as not authenticated by SMS (janko) (#235) * Use use_path option when rendering QR code to svg in the otp feature, to reduce svg size (jeremyevans) === 2.22.0 (2022-03-22) * Ignore parameters where the value includes a null byte by default, add null_byte_parameter_value configuration method for customization (jeremyevans) * Handle sessions created before active_sessions feature was enabled during logout (jeremyevans) (#224) * Add reset_password_notify for emailing users after successful password resets (jeremyevans) * An email method can now be used in external features to DRY up email creation code (jeremyevans) * The change_password_notify feature now correctly handles template precompilation (jeremyevans) * Fix update_sms to update stored sms hash (bjeanes) (#222) === 2.21.0 (2022-02-23) * Avoid extra bcrypt hashing on account verification when using account_password_hash_column (janko) (#217) * Make require_account public (janko) (#212) * Force specific date/time format when displaying webauthn last use time (jeremyevans) * Automatically clear the session in require_login if users go beyond verify account grace period (janko) (#211) * Fix typo in default value of global_logout_label in active_sessions plugin (sterlzbd) (#209) === 2.20.0 (2022-01-24) * Change the default implementation of webauth_rp_id to not include the port (jeremyevans) (#203) * Make logout of all sessions in active_sessions plugin also remove remember key if using remember plugin (jeremyevans) === 2.19.0 (2021-12-22) * Add login_maximum_bytes, setting the maximum number of bytes in a login, 255 by default (jeremyevans) * Add password_maximum_bytes, setting the maximum number of bytes in a password, nil by default for no limit (jeremyevans) * Add password_maximum_length, setting the maximum number of characters in a password, nil by default for no limit (jeremyevans) * Support multi-level inheritance of Rodauth::Auth (janko) (#191) * Allow internal_request feature to work correctly when loaded into custom Rodauth::Auth subclasses before loading into a Roda application (janko) (#190) * Assign internal subclass created by internal_request feature to the InternalRequest constant (janko) (#187) === 2.18.0 (2021-11-23) * Allow JSON API access to /multifactor-manage to get links to setup/disable multifactor authentication endpoints (jeremyevans) * Allow JSON API access to /multifactor-auth to get links to possible multifactor authentication endpoints (jeremyevans) * Set configuration_name on class passed via :auth_class option if not already set (janko, jeremyevans) (#181) * Use viewbox: true option when creating QR code in otp feature, displays better and easier to style when using rqrcode 2+ (jeremyevans) * Make argon2 feature work with argon2 2.1.0 (jeremyevans) === 2.17.0 (2021-09-24) * Make jwt_refresh work correctly with verify_account_grace_period (jeremyevans) * Use 4xx status code when attempting to login to or create an unverified account (janko) (#177, #178) === 2.16.0 (2021-08-23) * Add Rodauth.lib for using Rodauth as a library (jeremyevans) * Make internal_request feature work if the configuration uses only_json? true (janko) (#176) === 2.15.0 (2021-07-27) * Add path_class_methods feature, for getting paths/URLs using class methods (jeremyevans) * Make default base_url method use configured domain (janko) (#171) * Add internal_request feature, for interacting with Rodauth by calling methods (jeremyevans, janko) === 2.14.0 (2021-06-22) * Make jwt_refresh feature allow refresh with expired access tokens even if prefix is not set correctly (jeremyevans) (#168) * Make internal account_in_unverified_grace_period? method handle accounts missing or unverified accounts (janko, jeremyevans) (#167) * Add remembered_session_id configuration method for getting session id from valid remember token if present (bjeanes) (#166) === 2.13.0 (2021-05-22) * Make jwt_refresh expired access token support work when using rodauth.check_active_sessions before calling r.rodauth (renchap) (#165) * Update default templates to add classes for Bootstrap 5 compatibility (janko) (#164) * Add set_error_reason configuration method to allow applications more finer grained error handling (renchap, jeremyevans) (#162) === 2.12.0 (2021-04-22) * Add configuration methods to active_sessions plugin to control the inserting and updating of rows (janko) (#159) === 2.11.0 (2021-03-22) * Add same_as_current_login_message and contains_null_byte_message configuration methods to increase translatability (dmitryzuev) (#158) * Allow the rodauth plugin to be loaded without a block (janko) (#157) * Use new-password autocomplete value for the password fields on the reset password form (basabin54) (#155) * Support :auth_class plugin option, to use a specific class instead of creating a Rodauth::Auth subclass (janko) (#153) * Make Rodauth configuration work correctly if the rodauth plugin is loaded more than once (janko) (#152) === 2.10.0 (2021-02-22) * Add argon2 feature to allow use of the argon2 password hash algorithm instead of bcrypt (AlexeyMatskevich, jeremyevans) (#147) * Avoid unnecessary previous password queries when using disallow_password_reuse feature with create_account or verify_account features (AlexeyMatskevich, jeremyevans) (#148) === 2.9.0 (2021-01-22) * Split jwt feature into json and jwt features, with the json feature using standard session support (janko, jeremyevans) (#145) * Mark remember cookie as only transmitted over HTTPS by default if created via an HTTPS request (janko) (#144) === 2.8.0 (2021-01-06) * [SECURITY] Set HttpOnly on remember cookie by default so it cannot be accessed by Javascript (janko) (#142) * Clear JWT session when rodauth.clear_session is called if the Roda sessions plugin is used (janko) (#140) === 2.7.0 (2020-12-22) * Avoid method redefinition warnings in verbose warning mode (jeremyevans) * Return expired access token error message in the JWT refresh feature when using an expired token when it isn't allowed (AlexyMatskevich) (#133) * Allow Rodauth features to be preloaded, instead of always trying to require them (janko) (#136) * Use a default remember cookie path of '/', though this may cause problem with multiple Rodauth configurations on the same domain (janko) (#134) * Add auto_remove_recovery_codes? to the recovery_codes feature, for automatically removing the codes when disabling multifactor authentication (SilasSpet, jeremyevans) (#135) === 2.6.0 (2020-11-20) * Avoid loading features multiple times (janko) (#131) * Add around_rodauth method for running code around the handling of all Rodauth routes (bjeanes) (#129) * Fix javascript for registration of multiple webauthn keys (bjeanes) (#127) * Add allow_refresh_with_expired_jwt_access_token? configuration method to jwt_refresh feature, for allowing refresh with expired access token (jeremyevans) * Promote setup_account_verification to public API, useful for automatically sending account verification emails (jeremyevans) === 2.5.0 (2020-10-22) * Add change_login_needs_verification_notice_flash for easier translation of change_login_notice_flash when using verify_login_change (bjeanes, janko, jeremyevans) (#126) * Add login_return_to_requested_location_path for controlling path to use as the requested location (HoneyryderChuck, jeremyevans) (#122, #123) === 2.4.0 (2020-09-21) * Add session_key_prefix for more easily using separate session keys when using multiple configurations (janko) (#121) * Add password_pepper feature for appending a secret key to passwords before they are hashed, supporting secret rotation (janko) (#119) === 2.3.0 (2020-08-21) * Return an error status instead of an invalid access token when trying to refresh JWT without an access token in the jwt_refresh feature (jeremyevans) * Allow {create,drop}_database_authentication_functions to work with UUID keys (monorkin, janko) (#117) * Add rodauth.login('login_type') for logging in after setting a valid account (janko) (#114) * Make new refresh token available to the after_refresh_token hook by setting it in the response first (jeremyevans) * Make the jwt_refresh plugin call before_jwt_refresh_route hook (previously the configuration method was ignored) (AlexeyMatskevich) (#110) * Add login_email_regexp, login_not_valid_email_message, and log_valid_email? configuration methods (janko) (#107) === 2.2.0 (2020-07-20) * Allow removing all jwt_refresh tokens when logging out by providing a value of "all" as the token to remove (jeremyevans) * Allow removing specific jwt_refresh token when logging out by providing the token to remove (jeremyevans) * Avoid NoMethodError when checking if session is authenticated when using two factor auth, verify_account_grace_period, and email_auth (jeremyevans) (#105) * Reduce queries in #authenticated? and #require_authentication when using two factor authentication (janko) (#106) * Treat verify_account_email_resend returning false as an error in the verify_account feature (jeremyevans) * Fix use of password_dictionary configuration method in password_complexity feature (jeremyevans) * Remove unnecessary conditionals (jeremyevans) * Add otp_last_use to the otp feature, returning the time of last successful OTP use (jeremyevans) (#103) === 2.1.0 (2020-06-09) * Do not check CSRF tokens by default for requests using JWT (janko, jeremyevans) (#99) * Use new-password autocomplete value for password field when creating accounts (jeremyevans) (#98) * Consistently use json_response_body for all JSON responses in jwt feature (arthurmmoreira) (#97) * Add check_csrf configuration method to customize CSRF checking (janko) (#96) * Have logged_in? when using http_basic_auth feature check for basic authentication (jeremyevans) (#94) * Don't consider account open if in unverified grace period without password (janko) (#92) === 2.0.0 (2020-05-06) * Do not show email auth as an option for unverified accounts if using the verify_account_grace_period feature (jeremyevans) (#88) * Generate unlock account key outside of send_unlock_account_email, similar to other email methods (janko) (#89) * Default otp_drift to 30 in the otp feature (jeremyevans) * Add rodauth.require_http_basic_auth to http_basic_auth feature, similar to require_login (janko) (#86) * Rename require_http_basic_auth to require_http_basic_auth? in http_basic_auth feature (janko) (#86) * Change http_basic_auth feature to use rodauth.http_basic_auth for handling basic authentication, similar to rodauth.load_memory (janko) (#86) * Do not call already_logged_in if logged in when accessing verify_login_change page (janko) (#87) * HTML id attributes now use - instead of _ in recovery_codes and remember features (jeremyevans) * Allow *_path and *_url methods to accept a hash of query parameters (janko) (#84) * Use a danger button when closing accounts (janko) (#83) * Handle invalid form inputs in a more bootstrap compatible manner (janko) (#83) * Use standard vertical Bootstrap forms instead of horizontal forms in templates (janko) (#83) * Make templates compatible with Bootstrap 4, and still display correctly with Bootstrap 3 (janko) (#83) * Add check_csrf_opts and check_csrf_block for arguments to the check_csrf! call before Rodauth route dispatching (jeremyevans) * Add audit_logging feature, logging changes to a database table (jeremyevans) * Add hook_action configuration method, called after all before/after hooks (jeremyevans) * Enable email rate limiting by default in lockout, reset_password, and verify_account features (jeremyevans) * Add session_expiration_error_status method to the session_expiration feature, used for JSON requests where session has expired (jeremyevans) * Add domain configuration method to set an explicit domain, instead of relying on the host of the request (jeremyevans) * Add inactive_session_error_status to single_session feature, used for JSON requests where session is no longer active (jeremyevans) * Prevent use of previous JWT access tokens after refresh when using jwt_refresh and active_sessions features (jeremyevans) * Change default setting of jwt_check_accept? from false to true in the jwt feature (jeremyevans) * Automatically check CSRF tokens before calling any Rodauth route by default, allow disabling using check_csrf? false (jeremyevans) * Add translate(key, default_value) configuration method and have it affect all translatable content (jeremyevans) * Add *_page_title configuration methods for all *_view configuration methods (jeremyevans) * Default to using Roda's route_csrf plugin for CSRF support, with :csrf=>:rack_csrf available for using rack_csrf (jeremyevans) * Allow ability for user to fix an incorrect login when requesting a password reset (janko, jeremyevans) (#76) * Add two_factor_auth_return_to_requested_location? to support returning to original page after successful second factor authentication (janko) (#69) * Add login_return_to_requested_location? to support returning to original page after successful login (janko) (#69) * Add rodauth.require_password_authentication method to confirm_password feature (janko, jeremyevans) (#75) * Make remember feature no longer depend on confirm_password (janko) (#79) * Replace {create_account,reset_password_request,verify_account_resend}_link configuration methods with *_link_text (janko) (#77) * Remove remembered_session_key configuration method, no longer needed (janko) (#80) * Add rodauth.possible_authentication_methods for the available authentication methods for the account (jeremyevans) * Add active_sessions feature for disabling session reuse after logout, and allowing global logout of all sessions (jeremyevans) * Add webauthn_verify_account feature for passwordless WebAuthn setup during account verification (jeremyevans) * Allow confirm_password feature to operate as second factor authentication if using webauthn login (jeremyevans) * Add webauthn_login feature for passwordless login via WebAuthn (jeremyevans) * Do not allow two factor authentication using same type as primary authentication (jeremyevans) * Do not require passwords by default if the account does not have a password (jeremyevans) * Remove clear_remembered_session_key and two_factor_session_key configuration methods, no longer needed (jeremyevans) * Store authentication methods used in the session, available via rodauth.authenticated_by (jeremyevans) * Do not require login confirmation by default if verifying accounts or login changes (jeremyevans) * Add mark_input_fields_with_inputmode? and inputmode_for_field? configuration methods for controlling inputmode (jeremyevans) * Support and enable inputmode=numeric attributes by default for otp auth code and sms code fields (jeremyevans) * Add sms_phone_input_type and default to tel instead of using text for SMS phone input (jeremyevans) * Add mark_input_fields_with_autocomplete? and autocomplete_for_field? configuration methods for controlling autocomplete (jeremyevans) * Support and enable autocomplete attributes by default for fields (jeremyevans) * Add login_uses_email? configuration method for whether to treat logins as email addresses (jeremyevans) * Remove the verify change login feature, users should switch to the verify login change feature (jeremyevans) * Change default setting of json_response_success_key to success in the jwt feature (jeremyevans) * Remove deprecated account_model configuration method (jeremyevans) * Remove all deprecated configuration and runtime method aliases in the lockout, verify_account, email_auth, reset_password, and verify_login_change features (jeremyevans) * Remove deprecated before_otp_authentication_route configuration method (jeremyevans) * Change default setting of login_input_type to email if login_column is :email (jeremyevans) * Change default setting of mark_input_fields_as_required? to true (jeremyevans) * Change default setting of verify_account_set_password? in verify_account feature to true (jeremyevans) * Change default setting of json_response_custom_error_status? in jwt feature to true (jeremyevans) * Add auto_add_recovery_codes? configuration method to recovery codes feature, and default to false (jeremyevans) * Add base_url configuration method to set an explicit base for URLs, instead of relying on the base_url of the request (jeremyevans) * Add webauthn feature to handle WebAuthn authentication (jeremyevans) * Fix corner cases when disabling a second factor when multiple second factors have been setup (jeremyevans) * Don't override second factor used to authenticate when setting up additional second factor authentication (jeremyevans) * Add two factor auth, manage, and disable pages (jeremyevans) * Drop support for Ruby 1.8 (jeremyevans) === Older See doc/CHANGELOG.old jeremyevans-rodauth-b53f402/CONTRIBUTING000066400000000000000000000056211515725514200177020ustar00rootroot00000000000000Issue Guidelines ---------------- 1) Issues should only be created for things that are definitely bugs. If you are not sure that the behavior is a bug, ask about it on GitHub Discussions or the rodauth Google Group. GitHub Issues should not be used as a help forum. 2) If you are sure it is a bug, then post a complete description of the issue, the simplest possible self-contained example showing the problem and the full backtrace of any exception. 3) Issues are generally closed as soon as the problem is considered fixed. However, discussion can still happen after the issue is closed, and the issue will be reopened if additional evidence is providing showing the issue still exists. 4) Potential security issues can be publicly reported in the same manner as non-security issues (e.g. on GitHub Issues). However, if you would like to report them privately, you can report them via email to code@jeremyevans.net. Pull Request Guidelines ----------------------- 1) Try to include tests for all new features and substantial bug fixes. 2) Try to include documentation for all new features. In most cases this should include updates to files in the doc directory. 3) Follow the style conventions of the surrounding code. In most cases, this is standard ruby style. 4) Do not submit whitespace changes with code changes. Rodauth is not pedantic about trailing whitespace, so if you have an editor that automatically strips trailing whitespace, you may want to turn that feature off. 5) All code in pull requests is assumed to be MIT licensed. Do not submit a pull request if that isn't the case. 6) Please do not submit pull requests for code that is not ready to be merged. Pull requests should not be used to "start a conversation" about a possible code change. If the pull request requires a conversation, that conversation should take place on GitHub Discussions or the rodauth Google Group. 7) Pull requests are generally closed as soon as it appears that the branch will not be merged. However, discussion about the code can still happen after the pull request is closed, and the pull request can be reopened if additional commits to the branch or other changes make it likely that it will be merged. Code of Conduct --------------- This code of conduct applies to all of the project's "collaborative space", which is defined as community communications channels, including the Google Group, GitHub project, and source code repository. 1) Participants must ensure that their language and actions are free of personal attacks and remarks disparaging to people or groups. 2) Behaviour which can be reasonably considered harassment will not be tolerated. 3) Discussion should be limited to the project and related technologies. You can report a violation of this code of conduct to the project maintainer, who will take appropriate action. jeremyevans-rodauth-b53f402/MIT-LICENSE000066400000000000000000000020471515725514200175030ustar00rootroot00000000000000Copyright (c) 2015-2026 Jeremy Evans and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. jeremyevans-rodauth-b53f402/README.rdoc000066400000000000000000001716201515725514200176610ustar00rootroot00000000000000= Rodauth Rodauth is Ruby's most advanced authentication framework, designed to work in any rack application. It's built using Roda and Sequel, but it can be used with other web frameworks, database libraries, and databases. When used with PostgreSQL, MySQL, and Microsoft SQL Server in the default configuration, it offers additional security for password hashes by protecting access via database functions. Rodauth supports multiple multifactor authentication methods, multiple passwordless authentication methods, and offers both an HTML and JSON API for all supported features. == Design Goals * Security: Ship in a maximum security by default configuration * Simplicity: Allow for easy configuration via a DSL * Flexibility: Allow for easy overriding of any part of the framework === Feature Documentation The options/methods for the supported features are listed on a separate page per feature. If these links are not active, please view the appropriate file in the doc directory. * {Base}[rdoc-ref:doc/base.rdoc] (this feature is autoloaded) * {Login Password Requirements Base}[rdoc-ref:doc/login_password_requirements_base.rdoc] (this feature is autoloaded by features that set logins/passwords) * {Email Base}[rdoc-ref:doc/email_base.rdoc] (this feature is autoloaded by features that send email) * {Two Factor Base}[rdoc-ref:doc/two_factor_base.rdoc] (this feature is autoloaded by 2 factor authentication features) * {Account Expiration}[rdoc-ref:doc/account_expiration.rdoc] * {Active Sessions}[rdoc-ref:doc/active_sessions.rdoc] * {Audit Logging}[rdoc-ref:doc/audit_logging.rdoc] * {Argon2}[rdoc-ref:doc/argon2.rdoc] * {Change Login}[rdoc-ref:doc/change_login.rdoc] * {Change Password}[rdoc-ref:doc/change_password.rdoc] * {Change Password Notify}[rdoc-ref:doc/change_password_notify.rdoc] * {Close Account}[rdoc-ref:doc/close_account.rdoc] * {Confirm Password}[rdoc-ref:doc/confirm_password.rdoc] * {Create Account}[rdoc-ref:doc/create_account.rdoc] * {Disallow Common Passwords}[rdoc-ref:doc/disallow_common_passwords.rdoc] * {Disallow Password Reuse}[rdoc-ref:doc/disallow_password_reuse.rdoc] * {Email Authentication}[rdoc-ref:doc/email_auth.rdoc] * {HTTP Basic Auth}[rdoc-ref:doc/http_basic_auth.rdoc] * {Internal Request}[rdoc-ref:doc/internal_request.rdoc] * {JSON}[rdoc-ref:doc/json.rdoc] * {JWT CORS}[rdoc-ref:doc/jwt_cors.rdoc] * {JWT Refresh}[rdoc-ref:doc/jwt_refresh.rdoc] * {JWT}[rdoc-ref:doc/jwt.rdoc] * {Lockout}[rdoc-ref:doc/lockout.rdoc] * {Login}[rdoc-ref:doc/login.rdoc] * {Logout}[rdoc-ref:doc/logout.rdoc] * {OTP}[rdoc-ref:doc/otp.rdoc] * {OTP Lockout Email}[rdoc-ref:doc/otp_lockout_email.rdoc] * {OTP Modify Email}[rdoc-ref:doc/otp_modify_email.rdoc] * {OTP Unlock}[rdoc-ref:doc/otp_unlock.rdoc] * {Password Complexity}[rdoc-ref:doc/password_complexity.rdoc] * {Password Expiration}[rdoc-ref:doc/password_expiration.rdoc] * {Password Grace Period}[rdoc-ref:doc/password_grace_period.rdoc] * {Password Pepper}[rdoc-ref:doc/password_pepper.rdoc] * {Path Class Methods}[rdoc-ref:doc/path_class_methods.rdoc] * {Recovery Codes}[rdoc-ref:doc/recovery_codes.rdoc] * {Remember}[rdoc-ref:doc/remember.rdoc] * {Reset Password}[rdoc-ref:doc/reset_password.rdoc] * {Reset Password Notify}[rdoc-ref:doc/reset_password_notify.rdoc] * {Reset Password Verifies Account}[rdoc-ref:doc/reset_password_verifies_account.rdoc] * {Session Expiration}[rdoc-ref:doc/session_expiration.rdoc] * {Single Session}[rdoc-ref:doc/single_session.rdoc] * {SMS Codes}[rdoc-ref:doc/sms_codes.rdoc] * {Update Password Hash}[rdoc-ref:doc/update_password_hash.rdoc] * {Verify Account}[rdoc-ref:doc/verify_account.rdoc] * {Verify Account Grace Period}[rdoc-ref:doc/verify_account_grace_period.rdoc] * {Verify Login Change}[rdoc-ref:doc/verify_login_change.rdoc] * {WebAuthn}[rdoc-ref:doc/webauthn.rdoc] * {WebAuthn Autofill}[rdoc-ref:doc/webauthn_autofill.rdoc] * {WebAuthn Login}[rdoc-ref:doc/webauthn_login.rdoc] * {WebAuthn Modify Email}[rdoc-ref:doc/webauthn_modify_email.rdoc] * {WebAuthn Verify Account}[rdoc-ref:doc/webauthn_verify_account.rdoc] == Resources Website :: http://rodauth.jeremyevans.net Demo Site :: http://rodauth-demo.jeremyevans.net Source :: http://github.com/jeremyevans/rodauth Bugs :: http://github.com/jeremyevans/rodauth/issues Discussion Forum (GitHub Discussions) :: https://github.com/jeremyevans/rodauth/discussions Alternate Discussion Forum (Google Groups) :: https://groups.google.com/forum/#!forum/rodauth == Dependencies There are some dependencies that Rodauth uses depending on the features in use. These are development dependencies instead of runtime dependencies in the gem as it is possible to run without them: tilt :: Used by all features unless in JSON API only mode or using :render=>false plugin option. rack_csrf :: Used for CSRF support if the csrf: :rack_csrf plugin option is given (the default is to use Roda's route_csrf plugin, as that allows for more secure request-specific tokens). bcrypt :: Used by default for password hashing, can be skipped if password_match? is overridden for custom authentication. argon2 :: Used by the argon2 feature as alternative to bcrypt for password hashing. mail :: Used by default for mailing in the reset_password, verify_account, verify_login_change, change_password_notify, lockout, and email_auth features. rotp :: Used by the otp feature rqrcode :: Used by the otp feature jwt :: Used by the jwt feature webauthn :: Used by the webauthn feature You can use gem install --development rodauth to install the development dependencies in order to run tests. == Security === Password Hash Access Via Database Functions By default on PostgreSQL, MySQL, and Microsoft SQL Server, Rodauth uses database functions to access password hashes, with the user running the application unable to get direct access to password hashes. This reduces the risk of an attacker being able to access password hashes and use them to attack other sites. The rest of this section describes this feature in more detail, but note that Rodauth does not require this feature be used and works correctly without it. There may be cases where you cannot use this feature, such as when using a different database or when you do not have full control over the database you are using. Passwords are hashed using bcrypt by default, and the password hashes are kept in a separate table from the accounts table, with a foreign key referencing the accounts table. Two database functions are added, one to retrieve the salt for a password, and the other to check if a given password hash matches the password hash for the user. Two database accounts are used. The first is the account that the application uses, which is referred to as the +app+ account. The +app+ account does not have access to read the password hashes. The other account handles password hashes and is referred to as the +ph+ account. The +ph+ account sets up the database functions that can retrieve the salt for a given account's password, and check if a password hash matches for a given account. The +ph+ account sets these functions up so that the +app+ account can execute the functions using the +ph+ account's permissions. This allows the +app+ account to check passwords without having access to read password hashes. While the +app+ account is not be able to read password hashes, it is still be able to insert password hashes, update passwords hashes, and delete password hashes, so the additional security is not that painful. By disallowing the +app+ account access to the password hashes, it is much more difficult for an attacker to access the password hashes, even if they are able to exploit an SQL injection or remote code execution vulnerability in the application. The reason for extra security in regards to password hashes stems from the fact that people tend to choose poor passwords and reuse passwords, so a compromise of one database containing password hashes can result in account access on other sites, making password hash storage of critical importance even if the other data stored is not that important. If you are storing other sensitive information in your database, you should consider using a similar approach in other areas (or all areas) of your application. === Tokens Account verification, password resets, email auth, verify login change, remember, and lockout tokens all use a similar approach. They all provide a token, in the format "account-id_long-random-string". By including the id of the account in the token, an attacker can only attempt to bruteforce the token for a single account, instead of being able to bruteforce tokens for all accounts at once (which would be possible if the token was just a random string). Additionally, all comparisons of tokens use a timing-safe comparison function to reduce the risk of timing attacks. == HMAC By default, for backwards compatibility, Rodauth does not use HMACs, but you are strongly encouraged to use the +hmac_secret+ configuration method to set an HMAC secret. Setting an HMAC secret will enable HMACs for additional security, as described below. === email_base feature All features that send email use this feature. Setting +hmac_secret+ will make the tokens sent via email use an HMAC, while the raw token stored in the database will not use an HMAC. This will make it so if the tokens in the database are leaked (e.g. via an SQL injection vulnerability), they will not be usable without also having access to the +hmac_secret+. Without an HMAC, the raw token is sent in the email, and if the tokens in the database are leaked, they will be usable. To allow for an graceful transition, you can set +allow_raw_email_token?+ to true temporarily. This will allow the raw tokens in previous sent emails to still work. This should only be set temporarily as it removes the security that +hmac_secret+ adds. Most features that send email have tokens that expire by default in 1 day. The exception is the verify_account feature, which has tokens that do not expire. For the verify_account feature, if the user requested an email before +hmac_secret+ was set, after +allow_raw_email_token+ is no longer set, they will need to request the verification email be resent, in which case they will receive an email with a token that uses an HMAC. === remember feature Similar to the email_base feature, this uses HMACs for remember tokens, while storing the raw tokens in the database. This makes it so if the raw tokens in the database are leaked, the remember tokens are not usable without knowledge of the +hmac_secret+. The +raw_remember_token_deadline+ configuration method can be set to allow a previously set raw remember token to be used if the deadline for the remember token is before the given time. This allows for graceful transition to using HMACs for remember tokens. By default, the deadline is 14 days after the token is created, so this should be set to 14 days after the time you enable the HMAC for the remember feature if you are using the defaults. === otp feature Setting +hmac_secret+ will provide HMACed OTP keys to users, and would store the raw OTP keys in the database. This will make so if the raw OTP keys in the database are leaked, they will not be usable for two factor authentication without knowledge of the +hmac_secret+. Unfortunately, there can be no simple graceful transition for existing users. When introducing +hmac_secret+ to a Rodauth installation that already uses the otp feature, you will have to either revoke and replace all OTP keys, set +otp_keys_use_hmac?+ to false and continue to use raw OTP keys, or override +otp_keys_use_hmac?+ to return false if the user was issued an OTP key before +hmac_secret+ was added to the configuration, and true otherwise. +otp_keys_use_hmac?+ defaults to true if +hmac_secret+ is set, and false otherwise. If +otp_keys_use_hmac?+ is true, Rodauth will also ensure during OTP setup that the OTP key was generated by the server. If +otp_keys_use_hmac?+ is false, any OTP key in a valid format will be accepted during setup. If +otp_keys_use_hmac?+ is true, the jwt and otp features are in use and you are setting up OTP via JSON requests, you need to first send a POST request to the OTP setup route. This will return an error with the +otp_secret+ and +otp_raw_secret+ parameters in the JSON. These parameters should be submitted in the POST request to setup OTP, along with a valid OTP auth code for the +otp_secret+. === webauthn feature Setting +hmac_secret+ is required to use the webauthn feature, as it is used for checking that the provided authentication challenges have not been modified. === active_sessions feature Setting +hmac_secret+ is required to use the active_sessions feature, as the database stores an HMAC of the active session ID. === single_session feature Setting +hmac_secret+ will ensure the single session secret set in the session will be an HMACed. This does not affect security, as the session itself should at the least by protected by an HMAC (if not encrypted). This is only done for consistency, so that the raw tokens in the database are distinct from the tokens provided to the users. To allow for a graceful transition, +allow_raw_single_session_key?+ can be set to true. == PostgreSQL Database Setup In order to get full advantages of Rodauth's security design on PostgreSQL, multiple database accounts are involved: 1. database superuser account (usually postgres) 2. +app+ account (same name as application) 3. +ph+ account (application name with +_password+ appended) The database superuser account is used to load extensions related to the database. The application should never be run using the database superuser account. === Create database accounts If you are currently running your application using the database superuser account, the first thing you need to do is to create the +app+ database account. It's often best to name this account the same as the database name. You should also create the +ph+ database account which will handle access to the password hashes. Example for PostgreSQL: createuser -U postgres ${DATABASE_NAME} createuser -U postgres ${DATABASE_NAME}_password Note that if the database superuser account owns all of the items in the database, you'll need to change the ownership to the database account you just created. See https://gist.github.com/jeremyevans/8483320 for a way to do that. === Create database In general, the +app+ account is the owner of the database, since it will own most of the tables: createdb -U postgres -O ${DATABASE_NAME} ${DATABASE_NAME} Note that this is not the most secure way to develop applications. For maximum security, you would want to use a separate database account as the owner of the tables, have the +app+ account not be the owner of any tables, and specifically grant the +app+ account only the minimum access it needs to work correctly. Doing that is beyond the scope of Rodauth, though. === Load extensions If you want to use the login features for Rodauth, you need to load the citext extension if you want to support case insensitive logins. Example: psql -U postgres -c "CREATE EXTENSION citext" ${DATABASE_NAME} Note that on Heroku, this extension can be loaded using a standard database account. If you want logins to be case sensitive (generally considered a bad idea), you don't need to use the PostgreSQL citext extension. Just remember to modify the migration below to use +String+ instead of +citext+ for the email in that case. === Grant schema rights (PostgreSQL 15+) PostgreSQL 15 changed default database security so that only the database owner has writable access to the public schema. Rodauth expects the +ph+ account to have writable access to the public schema when setting things up. Temporarily grant that access (it will be revoked after the migration has run) psql -U postgres -c "GRANT CREATE ON SCHEMA public TO ${DATABASE_NAME}_password" ${DATABASE_NAME} === Using non-default schema PostgreSQL sets up new tables in the public schema by default. If you would like to use separate schemas per user, you can do: psql -U postgres -c "DROP SCHEMA public;" ${DATABASE_NAME} psql -U postgres -c "CREATE SCHEMA AUTHORIZATION ${DATABASE_NAME};" ${DATABASE_NAME} psql -U postgres -c "CREATE SCHEMA AUTHORIZATION ${DATABASE_NAME}_password;" ${DATABASE_NAME} psql -U postgres -c "GRANT USAGE ON SCHEMA ${DATABASE_NAME} TO ${DATABASE_NAME}_password;" ${DATABASE_NAME} psql -U postgres -c "GRANT USAGE ON SCHEMA ${DATABASE_NAME}_password TO ${DATABASE_NAME};" ${DATABASE_NAME} You'll need to modify the code to load the extension to specify the schema: psql -U postgres -c "CREATE EXTENSION citext SCHEMA ${DATABASE_NAME}" ${DATABASE_NAME} When running the migration for the +ph+ user you'll need to modify a couple things for the schema changes: create_table(:account_password_hashes) do foreign_key :id, Sequel[:${DATABASE_NAME}][:accounts], primary_key: true, type: :Bignum String :password_hash, null: false end Rodauth.create_database_authentication_functions(self, table_name: Sequel[:${DATABASE_NAME}_password][:account_password_hashes]) # if using the disallow_password_reuse feature: create_table(:account_previous_password_hashes) do primary_key :id, type: :Bignum foreign_key :account_id, Sequel[:${DATABASE_NAME}][:accounts], type: :Bignum String :password_hash, null: false end Rodauth.create_database_previous_password_check_functions(self, table_name: Sequel[:${DATABASE_NAME}_password][:account_previous_password_hashes]) You'll also need to use the following Rodauth configuration methods so that the app account calls functions in a separate schema: function_name do |name| "${DATABASE_NAME}_password.#{name}" end password_hash_table Sequel[:${DATABASE_NAME}_password][:account_password_hashes] # if using the disallow_password_reuse feature: previous_password_hash_table Sequel[:${DATABASE_NAME}_password][:account_previous_password_hashes] == MySQL Database Setup MySQL does not have the concept of object owners, and MySQL's GRANT/REVOKE support is much more limited than PostgreSQL's. When using MySQL, it is recommended to GRANT the +ph+ account ALL privileges on the database, including the ability to GRANT permissions to the +app+ account: CREATE USER '${DATABASE_NAME}'@'localhost' IDENTIFIED BY '${PASSWORD}'; CREATE USER '${DATABASE_NAME}_password'@'localhost' IDENTIFIED BY '${OTHER_PASSWORD}'; GRANT ALL ON ${DATABASE_NAME}.* TO '${DATABASE_NAME}_password'@'localhost' WITH GRANT OPTION; You should run all migrations as the +ph+ account, and GRANT specific access to the +app+ account as needed. Adding the database functions on MySQL may require setting the log_bin_trust_function_creators=1 setting in the MySQL configuration. == Microsoft SQL Server Database Setup Microsoft SQL Server has a concept of database owners, but similar to MySQL usage it's recommended to use the +ph+ account as the superuser for the database, and have it GRANT permissions to the +app+ account: CREATE LOGIN rodauth_test WITH PASSWORD = 'rodauth_test'; CREATE LOGIN rodauth_test_password WITH PASSWORD = 'rodauth_test'; CREATE DATABASE rodauth_test; USE rodauth_test; CREATE USER rodauth_test FOR LOGIN rodauth_test; GRANT CONNECT, EXECUTE TO rodauth_test; EXECUTE sp_changedbowner 'rodauth_test_password'; You should run all migrations as the +ph+ account, and GRANT specific access to the +app+ account as needed. == Creating tables Because two different database accounts are used, two different migrations are required, one for each database account. Here are example migrations. You can modify them to add support for additional columns, or remove tables or columns related to features that you don't need. First migration. On PostgreSQL, this should be run with the +app+ account, on MySQL and Microsoft SQL Server this should be run with the +ph+ account. Note that these migrations require Sequel 4.35.0+. Sequel.migration do up do extension :date_arithmetic # Used by the account verification and close account features create_table(:account_statuses) do Integer :id, primary_key: true String :name, null: false, unique: true end from(:account_statuses).import([:id, :name], [[1, 'Unverified'], [2, 'Verified'], [3, 'Closed']]) db = self create_table(:accounts) do primary_key :id, type: :Bignum foreign_key :status_id, :account_statuses, null: false, default: 1 if db.database_type == :postgres citext :email, null: false constraint :valid_email, email: /^[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+$/ else String :email, null: false end if db.supports_partial_indexes? index :email, unique: true, where: {status_id: [1, 2]} else index :email, unique: true end end deadline_opts = proc do |days| if database_type == :mysql {null: false} else {null: false, default: Sequel.date_add(Sequel::CURRENT_TIMESTAMP, days: days)} end end # Used by the audit logging feature json_type = case database_type when :postgres :jsonb when :sqlite sqlite_version >= 34500 ? :jsonb : :json when :mysql :json else String end create_table(:account_authentication_audit_logs) do primary_key :id, type: :Bignum foreign_key :account_id, :accounts, null: false, type: :Bignum # or, if allowing account deletions: # Bignum :account_id, null: false DateTime :at, null: false, default: Sequel::CURRENT_TIMESTAMP String :message, null: false column :metadata, json_type index [:account_id, :at], name: :audit_account_at_idx index :at, name: :audit_at_idx end # Used by the password reset feature create_table(:account_password_reset_keys) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :key, null: false DateTime :deadline, deadline_opts[1] DateTime :email_last_sent, null: false, default: Sequel::CURRENT_TIMESTAMP end # Used by the jwt refresh feature create_table(:account_jwt_refresh_keys) do primary_key :id, type: :Bignum foreign_key :account_id, :accounts, null: false, type: :Bignum String :key, null: false DateTime :deadline, deadline_opts[1] index :account_id, name: :account_jwt_rk_account_id_idx end # Used by the account verification feature create_table(:account_verification_keys) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :key, null: false DateTime :requested_at, null: false, default: Sequel::CURRENT_TIMESTAMP DateTime :email_last_sent, null: false, default: Sequel::CURRENT_TIMESTAMP end # Used by the verify login change feature create_table(:account_login_change_keys) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :key, null: false String :login, null: false DateTime :deadline, deadline_opts[1] end # Used by the remember me feature create_table(:account_remember_keys) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :key, null: false DateTime :deadline, deadline_opts[14] end # Used by the lockout feature create_table(:account_login_failures) do foreign_key :id, :accounts, primary_key: true, type: :Bignum Integer :number, null: false, default: 1 end create_table(:account_lockouts) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :key, null: false DateTime :deadline, deadline_opts[1] DateTime :email_last_sent end # Used by the email auth feature create_table(:account_email_auth_keys) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :key, null: false DateTime :deadline, deadline_opts[1] DateTime :email_last_sent, null: false, default: Sequel::CURRENT_TIMESTAMP end # Used by the password expiration feature create_table(:account_password_change_times) do foreign_key :id, :accounts, primary_key: true, type: :Bignum DateTime :changed_at, null: false, default: Sequel::CURRENT_TIMESTAMP end # Used by the account expiration feature create_table(:account_activity_times) do foreign_key :id, :accounts, primary_key: true, type: :Bignum DateTime :last_activity_at, null: false DateTime :last_login_at, null: false DateTime :expired_at end # Used by the single session feature create_table(:account_session_keys) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :key, null: false end # Used by the active sessions feature create_table(:account_active_session_keys) do foreign_key :account_id, :accounts, type: :Bignum String :session_id Time :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP Time :last_use, null: false, default: Sequel::CURRENT_TIMESTAMP primary_key [:account_id, :session_id] end # Used by the webauthn feature create_table(:account_webauthn_user_ids) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :webauthn_id, null: false end create_table(:account_webauthn_keys) do foreign_key :account_id, :accounts, type: :Bignum String :webauthn_id String :public_key, null: false Integer :sign_count, null: false Time :last_use, null: false, default: Sequel::CURRENT_TIMESTAMP primary_key [:account_id, :webauthn_id] end # Used by the otp feature create_table(:account_otp_keys) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :key, null: false Integer :num_failures, null: false, default: 0 Time :last_use, null: false, default: Sequel::CURRENT_TIMESTAMP end # Used by the otp_unlock feature create_table(:account_otp_unlocks) do foreign_key :id, :accounts, primary_key: true, type: :Bignum Integer :num_successes, null: false, default: 1 Time :next_auth_attempt_after, null: false, default: Sequel::CURRENT_TIMESTAMP end # Used by the recovery codes feature create_table(:account_recovery_codes) do foreign_key :id, :accounts, type: :Bignum String :code primary_key [:id, :code] end # Used by the sms codes feature create_table(:account_sms_codes) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :phone_number, null: false Integer :num_failures String :code DateTime :code_issued_at, null: false, default: Sequel::CURRENT_TIMESTAMP end case database_type when :postgres user = get(Sequel.lit('current_user')) + '_password' run "GRANT REFERENCES ON accounts TO #{user}" when :mysql, :mssql user = if database_type == :mysql get(Sequel.lit('current_user')).sub(/_password@/, '@') else get(Sequel.function(:DB_NAME)) end run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_statuses TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON accounts TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_authentication_audit_logs TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_password_reset_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_jwt_refresh_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_verification_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_login_change_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_remember_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_login_failures TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_email_auth_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_lockouts TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_password_change_times TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_activity_times TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_session_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_active_session_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_webauthn_user_ids TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_webauthn_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_otp_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_otp_unlocks TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_recovery_codes TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_sms_codes TO #{user}" end end down do drop_table(:account_sms_codes, :account_recovery_codes, :account_otp_unlocks, :account_otp_keys, :account_webauthn_keys, :account_webauthn_user_ids, :account_session_keys, :account_active_session_keys, :account_activity_times, :account_password_change_times, :account_email_auth_keys, :account_lockouts, :account_login_failures, :account_remember_keys, :account_login_change_keys, :account_verification_keys, :account_jwt_refresh_keys, :account_password_reset_keys, :account_authentication_audit_logs, :accounts, :account_statuses) end end Second migration, run using the +ph+ account: require 'rodauth/migrations' Sequel.migration do up do create_table(:account_password_hashes) do foreign_key :id, :accounts, primary_key: true, type: :Bignum String :password_hash, null: false end Rodauth.create_database_authentication_functions(self) case database_type when :postgres user = get(Sequel.lit('current_user')).sub(/_password\z/, '') run "REVOKE ALL ON account_password_hashes FROM public" run "REVOKE ALL ON FUNCTION rodauth_get_salt(int8) FROM public" run "REVOKE ALL ON FUNCTION rodauth_valid_password_hash(int8, text) FROM public" run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}" run "GRANT SELECT(id) ON account_password_hashes TO #{user}" run "GRANT EXECUTE ON FUNCTION rodauth_get_salt(int8) TO #{user}" run "GRANT EXECUTE ON FUNCTION rodauth_valid_password_hash(int8, text) TO #{user}" when :mysql user = get(Sequel.lit('current_user')).sub(/_password@/, '@') db_name = get(Sequel.function(:database)) run "GRANT EXECUTE ON #{db_name}.* TO #{user}" run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}" run "GRANT SELECT (id) ON account_password_hashes TO #{user}" when :mssql user = get(Sequel.function(:DB_NAME)) run "GRANT EXECUTE ON rodauth_get_salt TO #{user}" run "GRANT EXECUTE ON rodauth_valid_password_hash TO #{user}" run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}" run "GRANT SELECT ON account_password_hashes(id) TO #{user}" end # Used by the disallow_password_reuse feature create_table(:account_previous_password_hashes) do primary_key :id, type: :Bignum foreign_key :account_id, :accounts, type: :Bignum String :password_hash, null: false end Rodauth.create_database_previous_password_check_functions(self) case database_type when :postgres user = get(Sequel.lit('current_user')).sub(/_password\z/, '') run "REVOKE ALL ON account_previous_password_hashes FROM public" run "REVOKE ALL ON FUNCTION rodauth_get_previous_salt(int8) FROM public" run "REVOKE ALL ON FUNCTION rodauth_previous_password_hash_match(int8, text) FROM public" run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}" run "GRANT SELECT(id, account_id) ON account_previous_password_hashes TO #{user}" run "GRANT USAGE ON account_previous_password_hashes_id_seq TO #{user}" run "GRANT EXECUTE ON FUNCTION rodauth_get_previous_salt(int8) TO #{user}" run "GRANT EXECUTE ON FUNCTION rodauth_previous_password_hash_match(int8, text) TO #{user}" when :mysql user = get(Sequel.lit('current_user')).sub(/_password@/, '@') db_name = get(Sequel.function(:database)) run "GRANT EXECUTE ON #{db_name}.* TO #{user}" run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}" run "GRANT SELECT (id, account_id) ON account_previous_password_hashes TO #{user}" when :mssql user = get(Sequel.function(:DB_NAME)) run "GRANT EXECUTE ON rodauth_get_previous_salt TO #{user}" run "GRANT EXECUTE ON rodauth_previous_password_hash_match TO #{user}" run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}" run "GRANT SELECT ON account_previous_password_hashes(id, account_id) TO #{user}" end end down do Rodauth.drop_database_previous_password_check_functions(self) Rodauth.drop_database_authentication_functions(self) drop_table(:account_previous_password_hashes, :account_password_hashes) end end To support multiple separate migration users, you can run the migration for the password user using Sequel's migration API: Sequel.extension :migration Sequel.postgres('DATABASE_NAME', user: 'PASSWORD_USER_NAME') do |db| Sequel::Migrator.run(db, 'path/to/password_user/migrations', table: 'schema_info_password') end If the database is not PostgreSQL, MySQL, or Microsoft SQL Server, or you cannot use multiple user accounts, just combine the two migrations into a single migration, removing all the code related to database permissions and database functions. One thing to notice in the above migrations is that Rodauth uses additional tables for additional features, instead of additional columns in a single table. === Revoking schema rights (PostgreSQL 15+) If you explicit granted access to the public schema before running the migration, revoke it afterward: psql -U postgres -c "REVOKE CREATE ON SCHEMA public FROM ${DATABASE_NAME}_password" ${DATABASE_NAME} === Locking Down (PostgreSQL only) After running the migrations, you can increase security slightly by making it not possible for the +ph+ account to login to the database directly. This can be accomplished by modifying the +pg_hba.conf+ file. You can also consider restricting access using GRANT/REVOKE. You can restrict access to the database itself to just the +app+ account. You can run this using the +app+ account, since that account owns the database: GRANT ALL ON DATABASE ${DATABASE_NAME} TO ${DATABASE_NAME}; REVOKE ALL ON DATABASE ${DATABASE_NAME} FROM public; You can also restrict access to the public schema (this is not needed if you are using a custom schema). Note that by default, the database superuser owns the public schema, so you have to run this as the database superuser account (generally +postgres+): GRANT ALL ON SCHEMA public TO ${DATABASE_NAME}; GRANT USAGE ON SCHEMA public TO ${DATABASE_NAME}_password; REVOKE ALL ON SCHEMA public FROM public; If you are using MySQL or Microsoft SQL Server, please consult their documentation for how to restrict access so that the +ph+ account cannot login directly. == Usage === Basic Usage Rodauth is a Roda plugin and loaded the same way other Roda plugins are loaded: plugin :rodauth do end The block passed to the plugin call uses the Rodauth configuration DSL. The one configuration method that should always be used is +enable+, which chooses which features you would like to load: plugin :rodauth do enable :login, :logout end Once features are loaded, you can use any of the configuration methods supported by the features. There are two types of configuration methods. The first type are called auth methods, and they take a block which overrides the default method that Rodauth uses. Inside the block, you can call super if you want to get the default behavior, though you must provide explicit arguments to super. There is no need to call super in before or after hooks, though. For example, if you want to add additional logging when a user logs in: plugin :rodauth do enable :login, :logout after_login do LOGGER.info "#{account[:email]} logged in!" end end Inside the block, you are in the context of the Rodauth::Auth instance related to the request. This object has access to everything related to the request via methods: request :: RodaRequest instance response :: RodaResponse instance scope :: Roda instance session :: session hash flash :: flash message hash account :: account hash (if set by an earlier Rodauth method) current_route :: route name symbol (if Rodauth is handling the route) So if you want to log the IP address for the user during login: plugin :rodauth do enable :login, :logout after_login do LOGGER.info "#{account[:email]} logged in from #{request.ip}" end end The second type of configuration methods are called auth value methods. They are similar to auth methods, but instead of just accepting a block, they can optionally accept a single argument without a block, which will be treated as a block that just returns that value. For example, the accounts_table method sets the database table storing accounts, so to override it, you can call the method with a symbol for the table: plugin :rodauth do enable :login, :logout accounts_table :users end Note that all auth value methods can still take a block, allowing overriding for all behavior, using any information from the request: plugin :rodauth do enable :login, :logout accounts_table do request.ip.start_with?("192.168.1.") ? :admins : :users end end By allowing every configuration method to take a block, Rodauth should be flexible enough to integrate into most legacy systems. === Plugin Options When loading the rodauth plugin, you can also pass an options hash, which configures which dependent plugins should be loaded. Options: :csrf :: Set to +false+ to not load a csrf plugin. Set to +:rack_csrf+ to use the csrf plugin instead of the route_csrf plugin. :flash :: Set to +false+ to not load the flash plugin :render :: Set to +false+ to not load the render plugin. This is useful to avoid the dependency on tilt when using alternative view libraries. :json :: Set to +true+ to load the json and json_parser plugins. Set to +:only+ to only load those plugins and not any other plugins. Note that if you are enabling features that send email, you still need to load the render plugin manually. :name :: Provide a name for the given Rodauth configuration, used to support multiple Rodauth configurations in a given Roda application. :auth_class :: Provide a specific Rodauth::Auth subclass that should be set on the Roda application. By default, an anonymous Rodauth::Auth subclass is created. === Calling Rodauth in the Routing Tree In general, you will usually want to call +r.rodauth+ early in your route block: route do |r| r.rodauth # ... end Note that will allow Rodauth to run, but it won't force people to login or add any security to your site. If you want to force all users to login, you need to redirect to them login page if they are not already logged in: route do |r| r.rodauth rodauth.require_authentication # ... end If only certain parts of your site require logins, then you can only redirect if they are not logged in certain branches of the routing tree: route do |r| r.rodauth r.on "admin" do rodauth.require_authentication # ... end # ... end In some cases you may want to have rodauth run inside a branch of the routing tree, instead of in the root. You can do this by setting a +:prefix+ when configuring Rodauth, and calling +r.rodauth+ inside a matching routing tree branch: plugin :rodauth do enable :login, :logout prefix "/auth" end route do |r| r.on "auth" do r.rodauth end rodauth.require_authentication # ... end === +rodauth+ Methods Most of Rodauth's functionality is exposed via +r.rodauth+, which allows Rodauth to handle routes for the features you have enabled (such as +/login+ for login). However, as you have seen above, you may want to call methods on the +rodauth+ object, such as for checking if the current request has been authenticated. Here are methods designed to be callable on the +rodauth+ object outside +r.rodauth+: require_login :: Require the session be logged in, redirecting the request to the login page if the request has not been logged in. require_authentication :: Similar to +require_login+, but also requires two factor authentication if the account has setup two factor authentication. Redirects the request to the two factor authentication page if logged in but not authenticated via two factors. require_account :: Similar to +require_authentication+, but also loads the logged in account to ensure it exists in the database. If the account doesn't exist, or if it exists but isn't verified, the session is cleared and the request redirected to the login page. logged_in? :: Whether the session has been logged in. authenticated? :: Similar to +logged_in?+, but if the account has setup two factor authentication, whether the session has authenticated via two factors. account! :: Returns the current account record if it has already been loaded, otherwise retrieves the account from session if logged in. authenticated_by :: An array of strings for successful authentication methods for the current session (e.g. password/remember/webauthn). possible_authentication_methods :: An array of strings for possible authentication types that can be used for the account. autologin_type :: If the current session was authenticated via autologin, the type of autologin used. require_two_factor_setup :: (two_factor_base feature) Require the session to have setup two factor authentication, redirecting the request to the two factor authentication setup page if not. two_factor_partially_authenticated? :: (two_factor_base feature) Returns true if the session is logged in, the account has setup two factor authentication, but has not yet authenticated with a second factor. uses_two_factor_authentication? :: (two_factor_base feature) Whether the account for the current session has setup two factor authentication. update_last_activity :: (account_expiration feature) Update the last activity time for the current account. Only makes sense to use this if you are expiring accounts based on last activity. require_current_password :: (password_expiration feature) Require a current password, redirecting the request to the change password page if the password for the account has expired. require_password_authentication :: (confirm_password feature) If not authenticated via password and the account has a password, redirect to the password confirmation page, saving the current location to redirect back to after password has been successfully confirmed. If the password_grace_period feature is used, also redirect if the password has not been recently entered. load_memory :: (remember feature) If the session has not been authenticated, look for the remember cookie. If present and valid, automatically log the session in, but mark that it was logged in via a remember key. logged_in_via_remember_key? :: (remember feature) Whether the current session has been logged in via a remember key. For security sensitive actions where you want to require the user to reenter the password, you can use the confirm_password feature. http_basic_auth :: (http_basic_auth feature) Use HTTP Basic Authentication information to login the user if provided. require_http_basic_auth :: (http_basic_auth feature) Require that HTTP Basic Authentication be provided in the request. check_session_expiration :: (session_expiration feature) Check whether the current session has expired, automatically logging the session out if so. check_active_session :: (active_sessions feature) Check whether the current session is still active, automatically logging the session out if not. check_single_session :: (single_session feature) Check whether the current session is still the only valid session, automatically logging the session out if not. verified_account? :: (verify_grace_period feature) Whether the account is currently verified. If false, it is because the account is allowed to login as they are in the grace period. locked_out? :: (lockout feature) Whether the account for the current session has been locked out. authenticated_webauthn_id :: (webauthn feature) If the current session was authenticated via webauthn, the webauthn id of the credential used. *_path :: One of these is added for each of the routes added by Rodauth, giving the relative path to the route. Any options passed to this method will be converted into query parameters. *_url :: One of these is added for each of the routes added by Rodauth, giving the URL to the route. Any options passed to this method will be converted into query parameters. === Calling Rodauth Methods for Other Accounts In some cases, you may want to interact with Rodauth directly on behalf of a user. For example, let's say you want to create accounts or change passwords for existing accounts. Using Rodauth's internal_request feature, you can do this by: plugin :rodauth do enable :create_account, :change_password, :internal_request end rodauth.create_account(login: 'foo@example.com', password: '...') rodauth.change_password(account_id: 24601, password: '...') Here the +rodauth+ method is called as the Roda class level, which returns the appropriate Rodauth::Auth subclass. You call internal request methods on that class to perform actions on behalf of a user. See the {internal request feature documentation}[rdoc-ref:doc/internal_request.rdoc] for details. == Using Rodauth as a Library Rodauth was designed to serve as an authentication framework for Rack applications. However, Rodauth can be used purely as a library outside of a web application. You can do this by requiring +rodauth+, and using the +Rodauth.lib+ method to return a Rodauth::Auth subclass, which you can call methods on. You pass the +Rodauth.lib+ method an optional hash of Rodauth plugin options and a Rodauth configuration block: require 'rodauth' rodauth = Rodauth.lib do enable :create_account, :change_password end rodauth.create_account(login: 'foo@example.com', password: '...') rodauth.change_password(account_id: 24601, password: '...') This supports builds on top of the internal_request support (it implicitly loads the internal_request feature before processing the configuration block), and allows the use of Rodauth in non-web applications. Note that you still have to setup a Sequel::Database connection for Rodauth to use for data storage. === With Multiple Configurations Rodauth supports using multiple rodauth configurations in the same application. You just need to load the plugin a second time, providing a name for any alternate configuration: plugin :rodauth do end plugin :rodauth, name: :secondary do end Then in your routing code, any time you call rodauth, you can provide the name as an argument to use that configuration: route do |r| r.on 'secondary' do r.rodauth(:secondary) end r.rodauth end To prevent having to specify the name manually in all cases where you are using it, you can define a +default_rodauth_name+ method in your Roda application that returns the name that should be used: attr_reader :default_rodauth_name route do |r| r.on 'secondary' do @default_rodauth_name = :secondary r.rodauth # will use the :secondary configuration end r.rodauth # will use the default configuration end By default, alternate configurations will use the same session keys as the primary configuration, which may be undesirable. To ensure session state is separated between configurations, you can set a session key prefix for alternate configurations. If you are using the remember feature in both configurations, you may also want to set a different remember key in the alternate configuration: plugin :rodauth, name: :secondary do session_key_prefix "secondary_" remember_cookie_key "_secondary_remember" end === With Password Hashes Inside the Accounts Table You can use Rodauth if you are storing password hashes in the same table as the accounts. You just need to specify which column stores the password hash: plugin :rodauth do account_password_hash_column :password_hash end When this option is set, Rodauth will do the password hash check in ruby. === When Using PostgreSQL/MySQL/Microsoft SQL Server without Database Functions If you want to use Rodauth on PostgreSQL, MySQL, or Microsoft SQL Server without using database functions for authentication, but still storing password hashes in a separate table, you can do so: plugin :rodauth do use_database_authentication_functions? false end Conversely, if you implement the rodauth_get_salt and rodauth_valid_password_hash functions on a database that isn't PostgreSQL, MySQL, or Microsoft SQL Server, you can set this value to true. === With Custom Authentication You can use Rodauth with other authentication types, by using some of Rodauth's configuration methods. Note that when using custom authentication, using some of Rodauth's features such as change login and change password either would not make sense or would require some additional custom configuration. The login and logout features should work correctly with the examples below, though. ==== Using LDAP Authentication If you have accounts stored in the database, but authentication happens via LDAP, you can use the +simple_ldap_authenticator+ library: require 'simple_ldap_authenticator' plugin :rodauth do enable :login, :logout require_bcrypt? false password_match? do |password| SimpleLdapAuthenticator.valid?(account[:email], password) end end If you aren't storing accounts in the database, but want to allow any valid LDAP user to login, you can do something like this: require 'simple_ldap_authenticator' plugin :rodauth do enable :login, :logout # Don't require the bcrypt library, since using LDAP for auth require_bcrypt? false # Store session value in :login key, since the :account_id # default wouldn't make sense session_key :login # Use the login provided as the session value account_session_value{account} # Treat the login itself as the account account_from_login{|l| l.to_s} password_match? do |password| SimpleLdapAuthenticator.valid?(account, password) end end ==== Using Facebook Authentication Here's an example of authentication using Facebook with a JSON API. This setup assumes you have client-side code to submit JSON POST requests to +/login+ with an +access_token+ parameter that is set to the user's Facebook OAuth access token. require 'koala' plugin :rodauth do enable :login, :logout, :jwt require_bcrypt? false session_key :facebook_email account_session_value{account} login_param 'access_token' account_from_login do |access_token| fb = Koala::Facebook::API.new(access_token) if me = fb.get_object('me', fields: [:email]) me['email'] end end # there is no password! password_match? do |pass| true end end === With Rails If you're using Rails, you can use the {rodauth-rails}[https://github.com/janko/rodauth-rails] gem which provides Rails integration for Rodauth. Some of its features include: * generators for Rodauth & Sequel configuration, as well as views and mailers * uses Rails' flash messages and CSRF protection * automatically sets HMAC secret to Rails' secret key base * uses Action Controller & Action View for rendering templates * uses Action Mailer for sending emails Follow the instructions in the rodauth-rails README to get started. === With Other Web Frameworks You can use Rodauth even if your application does not use the Roda web framework. This is possible by adding a Roda middleware that uses Rodauth: require 'roda' class RodauthApp < Roda plugin :middleware plugin :rodauth do enable :login end route do |r| r.rodauth rodauth.require_authentication env['rodauth'] = rodauth end end use RodauthApp Note that Rodauth expects the Roda app it is used in to provide a layout. So if you are using Rodauth as middleware for another app, if you don't have a +views/layout.erb+ file that Rodauth can use, you should probably also add load Roda's +render+ plugin with the appropriate settings that allow Rodauth to use the same layout as the application. By setting env['rodauth'] = rodauth in the route block inside the middleware, you can easily provide a way for your application to call Rodauth methods. If you're using the remember feature with +extend_remember_deadline?+ set to true, you'll want to load roda's middleware plugin with forward_response_headers: true option, so that +Set-Cookie+ header changes from the +load_memory+ call in the route block are propagated when the request is forwarded to the main app. Here are some examples of integrating Rodauth into applications that don't use Roda: * {Ginatra, a Sinatra-based git repository viewer}[https://github.com/jeremyevans/ginatra/commit/28108ebec96e8d42596ee55b01c3f7b50c155dd1] * {Rodauth's demo site as a Rails 6 application}[https://github.com/janko/rodauth-demo-rails] * {Grape application}[https://github.com/davydovanton/grape-rodauth] * {Hanami application}[https://github.com/davydovanton/rodauth_hanami] === Using 2 Factor Authentication Rodauth ships with 2 factor authentication support via the following methods: * WebAuthn * TOTP (Time-Based One-Time Passwords, RFC 6238). * SMS Codes * Recovery Codes There are multiple ways to integrate 2 factor authentication with Rodauth, based on the needs of the application. By default, SMS codes and recovery codes are treated only as backup 2nd factors, a user cannot enable them without first enabling another 2nd factor authentication method. However, you can change this by using a configuration method. If you want to support but not require 2 factor authentication: plugin :rodauth do enable :login, :logout, :otp, :recovery_codes, :sms_codes end route do |r| r.rodauth rodauth.require_authentication # ... end If you want to force all users to use 2 factor authentication, requiring users that don't currently have two authentication to set it up: route do |r| r.rodauth rodauth.require_authentication rodauth.require_two_factor_setup # ... end Similarly to requiring authentication in general, it's possible to require login authentication for most of the site, but require 2 factor authentication only for particular branches: route do |r| r.rodauth rodauth.require_login r.on "admin" do rodauth.require_two_factor_authenticated end # ... end === JSON API Support To add support for handling JSON responses, you can pass the +:json+ option to the plugin, and enable the JWT feature in addition to other features you plan to use: plugin :rodauth, json: true do enable :login, :logout, :jwt end If you do not want to load the HTML plugins that Rodauth usually loads (render, csrf, flash, h), because you are building a JSON-only API, pass :json => :only plugin :rodauth, json: :only do enable :login, :logout, :jwt end Note that by default, the features that send email depend on the render plugin, so if using the json: :only option, you either need to load the render plugin manually or you need to use the necessary *_email_body configuration options to specify the body of the emails. The JWT feature enables JSON API support for all of the other features that Rodauth ships with. If you would like JSON API access that still uses rack session for storing session data, enable the JSON feature instead: plugin :rodauth, json: true do enable :login, :logout, :json only_json? true # if you want to only handle JSON requests end === Adding Custom Methods to the +rodauth+ Object Inside the configuration block, you can use +auth_class_eval+ to add custom methods that will be callable on the +rodauth+ object. plugin :rodauth do enable :login auth_class_eval do def require_admin request.redirect("/") unless account[:admin] end end end route do |r| r.rodauth r.on "admin" do rodauth.require_admin end end === Using External Features The +enable+ configuration method is able to load features external to Rodauth. You need to place the external feature file where it can be required via rodauth/features/feature_name. That file should use the following basic structure module Rodauth # :feature_name will be the argument given to enable to # load the feature, :FeatureName is optional and will be used to # set a constant name for prettier inspect output. Feature.define(:feature_name, :FeatureName) do # Shortcut for defining auth value methods with static values auth_value_method :method_name, 1 # method_value auth_value_methods # one argument per auth value method auth_methods # one argument per auth method route do |r| # This block is taken for requests to the feature's route. # This block is evaluated in the scope of the Rodauth::Auth instance. # r is the Roda::RodaRequest instance for the request r.get do end r.post do end end configuration_module_eval do # define additional configuration specific methods here, if any end # define the default behavior for the auth_methods # and auth_value_methods # ... end end See the {internals guide}[rdoc-ref:doc/guides/internals.rdoc] for a more complete example of how to construct features. === Overriding Route-Level Behavior All of Rodauth's configuration methods change the behavior of the Rodauth::Auth instance. However, in some cases you may want to overriding handling at the routing layer. You can do this easily by adding an appropriate route before calling +r.rodauth+: route do |r| r.post 'login' do # Custom POST /login handling here end r.rodauth end === Precompiling Rodauth Templates Rodauth serves templates from it's gem folder. If you are using a forking webserver and want to preload the compiled templates to save memory, or if you are chrooting your application, you can benefit from precompiling your rodauth templates: plugin :rodauth do # ... end precompile_rodauth_templates == Ruby Support Policy Rodauth fully supports the currently supported versions of Ruby (MRI) and JRuby. It may support unsupported versions of Ruby or JRuby, but such support may be dropped in any minor version if keeping it becomes a support issue. The minimum Ruby version required to run the current version of Rodauth is 1.9.2. == Similar Projects All of these are Rails-specific: * {Devise}[https://github.com/heartcombo/devise] * {Authlogic}[https://github.com/binarylogic/authlogic] * {Sorcery}[https://github.com/Sorcery/sorcery] == Author Jeremy Evans jeremyevans-rodauth-b53f402/Rakefile000066400000000000000000000154141515725514200175160ustar00rootroot00000000000000require "rake" require "rake/clean" CLEAN.include ["rodauth-*.gem", "coverage", "www/public/rdoc", "www/public/*.html"] # Packaging desc "Build rodauth gem" task :package=>[:clean] do |p| sh %{#{FileUtils::RUBY} -S gem build rodauth.gemspec} end ### RDoc desc "Generate rdoc" task :website_rdoc do rdoc_dir = "www/public/rdoc" rdoc_opts = ["--line-numbers", "--inline-source", '--title', 'Rodauth: Authentication and Account Management Framework for Rack Applications'] begin gem 'hanna' rdoc_opts.concat(['-f', 'hanna']) rescue Gem::LoadError end rdoc_opts.concat(['--main', 'README.rdoc', "-o", rdoc_dir]) rdoc_opts.concat(%w"README.rdoc CHANGELOG doc/CHANGELOG.old MIT-LICENSE" + Dir["lib/**/*.rb"] + Dir["doc/**/*.rdoc"] + Dir['doc/release_notes/*.txt']) FileUtils.rm_rf(rdoc_dir) require "rdoc" RDoc::RDoc.new.document(rdoc_opts) end desc "Check configuration method documentation" task :check_method_doc do docs = {} Dir["doc/*.rdoc"].sort.each do |f| meths = File.binread(f).split("\n").grep(/\A(\w+[!?]?(\([^\)]+\))?) :: /).map{|line| line.split(/( :: |\()/, 2)[0]}.sort docs[File.basename(f).sub(/\.rdoc\z/, '')] = meths unless meths.empty? end require "rack/version" require './lib/rodauth' docs.each do |f, doc_meths| require "./lib/rodauth/features/#{f}" feature = Rodauth::FEATURES[f.to_sym] meths = (feature.auth_methods + feature.auth_value_methods + feature.auth_private_methods).map(&:to_s).sort unless (undocumented_meths = meths - doc_meths).empty? puts "#{f} undocumented methods: #{undocumented_meths.join(', ')}" end unless (bad_doc_meths = doc_meths - meths).empty? puts "#{f} documented methods that don't exist: #{bad_doc_meths.join(', ')}" end end puts "#{docs.values.flatten.length} total documented configuration methods" end # Specs adapters = if RUBY_ENGINE == 'jruby' {:mysql=>'jdbc:mysql', :mssql=>'jdbc:jtds:sqlserver', :postgres=>'jdbc:postgresql'} else {:mysql=>'mysql2', :mssql=>'tinytds', :postgres=>'postgres'} end desc "Run specs" task :default=>:spec spec = proc do |env| env.each{|k,v| ENV[k] = v} sh "#{FileUtils::RUBY} #{"-w" if RUBY_VERSION >= '3'} #{'-W:strict_unused_block' if RUBY_VERSION >= '3.4'} spec/all.rb" env.each{|k,v| ENV.delete(k)} end desc "Run specs on PostgreSQL" task "spec" do spec.call({}) end desc "Run specs with method visibility checking" task "spec_vis" do spec.call('CHECK_METHOD_VISIBILITY'=>'1') end desc "Run specs with coverage" task "spec_cov" do spec.call('COVERAGE'=>'1') end desc "Run specs with Rack::Lint" task "spec_lint" do spec.call('LINT'=>'1') end desc "Setup database used for testing on PostgreSQL" task :db_setup_postgres do sh 'psql -U postgres -c "CREATE USER rodauth_test PASSWORD \'rodauth_test\'"' sh 'psql -U postgres -c "CREATE USER rodauth_test_password PASSWORD \'rodauth_test\'"' sh 'createdb -U postgres -O rodauth_test rodauth_test' sh 'psql -U postgres -c "CREATE EXTENSION citext" rodauth_test' sh 'psql -U postgres -c "CREATE EXTENSION pgcrypto" rodauth_test' $: << 'lib' require 'sequel' Sequel.extension :migration Sequel.connect("#{adapters[:postgres]}:///rodauth_test?user=rodauth_test&password=rodauth_test") do |db| Sequel::Migrator.run(db, 'spec/migrate') end sh 'psql -U postgres -c "GRANT CREATE ON SCHEMA public TO rodauth_test_password" rodauth_test' Sequel.connect("#{adapters[:postgres]}:///rodauth_test?user=rodauth_test_password&password=rodauth_test") do |db| Sequel::Migrator.run(db, 'spec/migrate_password', :table=>'schema_info_password') end sh 'psql -U postgres -c "REVOKE CREATE ON SCHEMA public FROM rodauth_test_password" rodauth_test' end desc "Teardown database used for testing on MySQL" task :db_teardown_postgres do sh 'dropdb -U postgres rodauth_test' sh 'dropuser -U postgres rodauth_test_password' sh 'dropuser -U postgres rodauth_test' end desc "Setup database used for testing on MySQL" task :db_setup_mysql do sh 'mysql --user=root -p mysql < spec/sql/mysql_setup.sql' $: << 'lib' require 'sequel' Sequel.extension :migration Sequel.connect("#{adapters[:mysql]}:///rodauth_test?user=rodauth_test_password&password=rodauth_test") do |db| Sequel::Migrator.run(db, 'spec/migrate') Sequel::Migrator.run(db, 'spec/migrate_password', :table=>'schema_info_password') end end desc "Teardown database used for testing on MySQL" task :db_teardown_mysql do sh 'mysql --user=root -p mysql < spec/sql/mysql_teardown.sql' end desc "Setup database used for testing on Microsoft SQL Server" task :db_setup_mssql do sh 'sqlcmd -E -e -b -r1 -i spec\\sql\\mssql_setup.sql' $: << 'lib' require 'sequel' Sequel.extension :migration sep = ';' if RUBY_ENGINE == 'jruby' Sequel.connect("#{adapters[:mssql]}://localhost/rodauth_test#{sep || '?'}user=rodauth_test_password#{sep || '&'}password=Rodauth1.") do |db| Sequel::Migrator.run(db, 'spec/migrate') Sequel::Migrator.run(db, 'spec/migrate_password', :table=>'schema_info_password') end end desc "Teardown database used for testing on Microsoft SQL Server" task :db_teardown_mssql do sh 'sqlcmd -E -e -b -r1 -i spec\\sql\\mssql_teardown.sql' end desc "Run specs on MySQL" task :spec_mysql do spec.call('RODAUTH_SPEC_DB'=>"#{adapters[:mysql]}://localhost/rodauth_test?user=rodauth_test&password=rodauth_test") end task :spec_ci do mysql_host = "localhost" pg_database = "rodauth_test" unless ENV["DEFAULT_DATABASE"] ENV['LINT'] = '1' if RUBY_VERSION >= '3.0' if ENV["MYSQL_ROOT_PASSWORD"] mysql_password = "&password=root" mysql_host= "127.0.0.1:3306" end if RUBY_ENGINE == 'jruby' pg_db = "jdbc:postgresql://localhost/#{pg_database}?user=postgres&password=postgres" my_db = "jdbc:mysql://#{mysql_host}/rodauth_test?user=root#{mysql_password}&useSSL=false&allowPublicKeyRetrieval=true" else pg_db = "postgres://localhost/#{pg_database}?user=postgres&password=postgres" my_db = "mysql2://#{mysql_host}/rodauth_test?user=root#{mysql_password}&useSSL=false" end require "sequel/core" Sequel.connect(pg_db) do |db| db.run 'CREATE EXTENSION citext' db.run 'CREATE EXTENSION pgcrypto' if ENV['RODAUTH_SPEC_UUID'] end spec.call('RODAUTH_SPEC_MIGRATE'=>'1', 'RODAUTH_SPEC_DB'=>pg_db) if RUBY_VERSION >= '2.4' spec.call('RODAUTH_SPEC_MIGRATE'=>'1', 'RODAUTH_SPEC_DB'=>my_db) Rake::Task['spec_sqlite'].invoke end end desc "Run specs on SQLite" task :spec_sqlite do conn_string = if RUBY_ENGINE == 'jruby' 'jdbc:sqlite::memory:' else 'sqlite:/' end spec.call('RODAUTH_SPEC_MIGRATE'=>'1', 'RODAUTH_SPEC_DB'=>conn_string) end ### Website desc "Make local version of website" task :website_base do sh %{#{FileUtils::RUBY} -I lib www/make_www.rb} end desc "Make local version of website, with rdoc" task :website => [:website_base, :website_rdoc] jeremyevans-rodauth-b53f402/SECURITY.md000066400000000000000000000004241515725514200176350ustar00rootroot00000000000000# Security Policy ## Reporting a Vulnerability Potential security issues can be publicly reported in the same manner as non-security issues (e.g. on GitHub Issues). However, if you would like to report them privately, you can report them via email to code@jeremyevans.net. jeremyevans-rodauth-b53f402/demo-site/000077500000000000000000000000001515725514200177325ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/demo-site/config.ru000066400000000000000000000002111515725514200215410ustar00rootroot00000000000000$:.unshift(::File.expand_path('../../lib', __FILE__)) require ::File.expand_path('../rodauth_demo', __FILE__) run RodauthDemo::App.app jeremyevans-rodauth-b53f402/demo-site/rodauth_demo.rb000066400000000000000000000057661515725514200227470ustar00rootroot00000000000000require 'roda' require 'sequel/core' require 'mail' require 'securerandom' module RodauthDemo class App < Roda if url = ENV.delete('RODAUTH_DATABASE_URL') || ENV.delete('DATABASE_URL') DB = Sequel.connect(url) if DB.adapter_scheme == :postgres && Sequel::Postgres::USES_PG begin DB.extension :pg_auto_parameterize rescue LoadError end end else DB = Sequel.sqlite Sequel.extension :migration Sequel::Migrator.run(DB, File.expand_path('../../spec/migrate_ci', __FILE__)) end if ENV.delete('RODAUTH_DEMO_LOGGER') require 'logger' DB.loggers << Logger.new($stdout) end DB.extension :date_arithmetic DB.freeze ::Mail.defaults do delivery_method :test end opts[:root] = File.dirname(__FILE__) MAILS = {} SMS = {} MUTEX = Mutex.new plugin :render, :escape=>true plugin :request_aref, :raise plugin :hooks plugin :flash plugin :common_logger plugin :route_csrf plugin :disallow_file_uploads secret = ENV.delete('RODAUTH_SESSION_SECRET') || SecureRandom.random_bytes(64) plugin :sessions, :secret=>secret, :key=>'rodauth-demo.session' plugin :rodauth, :json=>true, :csrf=>:route_csrf do db DB enable :change_login, :change_password, :close_account, :create_account, :lockout, :login, :logout, :remember, :reset_password_verifies_account, :otp_modify_email, :otp_lockout_email, :recovery_codes, :sms_codes, :disallow_common_passwords, :disallow_password_reuse, :password_grace_period, :active_sessions, :jwt, :verify_login_change, :change_password_notify, :confirm_password, :email_auth enable :webauthn, :webauthn_login, :webauthn_modify_email if ENV["RODAUTH_WEBAUTHN"] enable :webauthn_verify_account if ENV["RODAUTH_WEBAUTHN_VERIFY_ACCOUNT"] enable :webauthn_autofill if ENV["RODAUTH_WEBAUTHN_AUTOFILL"] max_invalid_logins 2 account_password_hash_column :ph title_instance_variable :@page_title only_json? false jwt_secret(secret) hmac_secret secret sms_send do |phone_number, message| MUTEX.synchronize{SMS[session_value] = "Would have sent the following SMS to #{phone_number}: #{message}"} end end plugin :error_handler do |e| @page_title = "Internal Server Error" view :content=>"#{h e.class}: #{h e.message}
#{e.backtrace.map{|line| h line}.join("
")}" end plugin :not_found do @page_title = "File Not Found" view :content=>"" end def last_sms_sent MUTEX.synchronize{SMS.delete(rodauth.session_value)} end def last_mail_sent MUTEX.synchronize{MAILS.delete(rodauth.session_value)} end after do Mail::TestMailer.deliveries.each do |mail| MUTEX.synchronize{MAILS[rodauth.session_value] = mail} end Mail::TestMailer.deliveries.clear end route do |r| check_csrf! unless r.env['CONTENT_TYPE'] =~ /application\/json/ rodauth.load_memory rodauth.check_active_session r.rodauth r.root do view 'index' end end freeze end end jeremyevans-rodauth-b53f402/demo-site/views/000077500000000000000000000000001515725514200210675ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/demo-site/views/index.erb000066400000000000000000000037401515725514200226740ustar00rootroot00000000000000<% if rodauth.logged_in? %>

You are logged in as <%= DB[:accounts].where(:id=>rodauth.session_value).get(:email) %>

<% if rodauth.two_factor_authenticated? %>

You have authenticated using multifactor authentication (<%= rodauth.authenticated_by.join(' and ') %>).

<% elsif rodauth.uses_two_factor_authentication? %>

You have logged in via single factor authentication (<%= rodauth.authenticated_by.first %>), but have not authenticated using multifactor authentication.

<% else %>

You have authenticated via <%= rodauth.authenticated_by.first %>

<% end %> <% else %>

Overview

This is the demo site for Rodauth. Rodauth is an authentication and account management framework for rack applications, with the following design goals:

  1. Security: Ship in a maximum security by default configuration
  2. Simplicity: Allow for easy configuration via a DSL
  3. Flexibility: Allow for easy overriding of any part of the framework

It's easiest to get started by going to the login page.

This demo site is part of the Rodauth repository, so if you want to know how it works, you can review the source.

<% end %> jeremyevans-rodauth-b53f402/demo-site/views/layout.erb000066400000000000000000000033451515725514200231030ustar00rootroot00000000000000 Rodauth Demo - <%= @page_title %>
<% if sms = last_sms_sent %>

<%= sms %>

<% end %> <% if flash['notice'] %>

<%= flash['notice'] %>

<% end %> <% if flash['error'] %>

<%= flash['error'] %>

<% end %>

<%= @page_title %>

<%== yield %> <% if mail = last_mail_sent %>

Last Email Sent

From: <%= mail.from.join %>
To: <%= mail.to.join %>
Subject: <%= mail.subject %>

<%= mail.body %>
<% end %>
jeremyevans-rodauth-b53f402/dict/000077500000000000000000000000001515725514200167675ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/dict/top-10_000-passwords.txt000066400000000000000000002253341515725514200231630ustar00rootroot00000000000000123456 password 12345678 qwerty 123456789 12345 1234 111111 1234567 dragon 123123 baseball abc123 football monkey letmein 696969 shadow master 666666 qwertyuiop 123321 mustang 1234567890 michael 654321 pussy superman 1qaz2wsx 7777777 fuckyou 121212 000000 qazwsx 123qwe killer trustno1 jordan jennifer zxcvbnm asdfgh hunter buster soccer harley batman andrew tigger sunshine iloveyou fuckme 2000 charlie robert thomas hockey ranger daniel starwars klaster 112233 george asshole computer michelle jessica pepper 1111 zxcvbn 555555 11111111 131313 freedom 777777 pass fuck maggie 159753 aaaaaa ginger princess joshua cheese amanda summer love ashley 6969 nicole chelsea biteme matthew access yankees 987654321 dallas austin thunder taylor matrix william corvette hello martin heather secret fucker merlin diamond 1234qwer gfhjkm hammer silver 222222 88888888 anthony justin test bailey q1w2e3r4t5 patrick internet scooter orange 11111 golfer cookie richard samantha bigdog guitar jackson whatever mickey chicken sparky snoopy maverick phoenix camaro sexy peanut morgan welcome falcon cowboy ferrari samsung andrea smokey steelers joseph mercedes dakota arsenal eagles melissa boomer booboo spider nascar monster tigers yellow xxxxxx 123123123 gateway marina diablo bulldog qwer1234 compaq purple hardcore banana junior hannah 123654 porsche lakers iceman money cowboys 987654 london tennis 999999 ncc1701 coffee scooby 0000 miller boston q1w2e3r4 fuckoff brandon yamaha chester mother forever johnny edward 333333 oliver redsox player nikita knight fender barney midnight please brandy chicago badboy iwantu slayer rangers charles angel flower bigdaddy rabbit wizard bigdick jasper enter rachel chris steven winner adidas victoria natasha 1q2w3e4r jasmine winter prince panties marine ghbdtn fishing cocacola casper james 232323 raiders 888888 marlboro gandalf asdfasdf crystal 87654321 12344321 sexsex golden blowme bigtits 8675309 panther lauren angela bitch spanky thx1138 angels madison winston shannon mike toyota blowjob jordan23 canada sophie Password apples dick tiger razz 123abc pokemon qazxsw 55555 qwaszx muffin johnson murphy cooper jonathan liverpoo david danielle 159357 jackie 1990 123456a 789456 turtle horny abcd1234 scorpion qazwsxedc 101010 butter carlos password1 dennis slipknot qwerty123 booger asdf 1991 black startrek 12341234 cameron newyork rainbow nathan john 1992 rocket viking redskins butthead asdfghjkl 1212 sierra peaches gemini doctor wilson sandra helpme qwertyui victor florida dolphin pookie captain tucker blue liverpool theman bandit dolphins maddog packers jaguar lovers nicholas united tiffany maxwell zzzzzz nirvana jeremy suckit stupid porn monica elephant giants jackass hotdog rosebud success debbie mountain 444444 xxxxxxxx warrior 1q2w3e4r5t q1w2e3 123456q albert metallic lucky azerty 7777 shithead alex bond007 alexis 1111111 samson 5150 willie scorpio bonnie gators benjamin voodoo driver dexter 2112 jason calvin freddy 212121 creative 12345a sydney rush2112 1989 asdfghjk red123 bubba 4815162342 passw0rd trouble gunner happy fucking gordon legend jessie stella qwert eminem arthur apple nissan bullshit bear america 1qazxsw2 nothing parker 4444 rebecca qweqwe garfield 01012011 beavis 69696969 jack asdasd december 2222 102030 252525 11223344 magic apollo skippy 315475 girls kitten golf copper braves shelby godzilla beaver fred tomcat august buddy airborne 1993 1988 lifehack qqqqqq brooklyn animal platinum phantom online xavier darkness blink182 power fish green 789456123 voyager police travis 12qwaszx heaven snowball lover abcdef 00000 pakistan 007007 walter playboy blazer cricket sniper hooters donkey willow loveme saturn therock redwings bigboy pumpkin trinity williams tits nintendo digital destiny topgun runner marvin guinness chance bubbles testing fire november minecraft asdf1234 lasvegas sergey broncos cartman private celtic birdie little cassie babygirl donald beatles 1313 dickhead family 12121212 school louise gabriel eclipse fluffy 147258369 lol123 explorer beer nelson flyers spencer scott lovely gibson doggie cherry andrey snickers buffalo pantera metallica member carter qwertyu peter alexande steve bronco paradise goober 5555 samuel montana mexico dreams michigan cock carolina yankee friends magnum surfer poopoo maximus genius cool vampire lacrosse asd123 aaaa christin kimberly speedy sharon carmen 111222 kristina sammy racing ou812 sabrina horses 0987654321 qwerty1 pimpin baby stalker enigma 147147 star poohbear boobies 147258 simple bollocks 12345q marcus brian 1987 qweasdzxc drowssap hahaha caroline barbara dave viper drummer action einstein bitches genesis hello1 scotty friend forest 010203 hotrod google vanessa spitfire badger maryjane friday alaska 1232323q tester jester jake champion billy 147852 rock hawaii badass chevy 420420 walker stephen eagle1 bill 1986 october gregory svetlana pamela 1984 music shorty westside stanley diesel courtney 242424 kevin porno hitman boobs mark 12345qwert reddog frank qwe123 popcorn patricia aaaaaaaa 1969 teresa mozart buddha anderson paul melanie abcdefg security lucky1 lizard denise 3333 a12345 123789 ruslan stargate simpsons scarface eagle 123456789a thumper olivia naruto 1234554321 general cherokee a123456 vincent Usuckballz1 spooky qweasd cumshot free frankie douglas death 1980 loveyou kitty kelly veronica suzuki semperfi penguin mercury liberty spirit scotland natalie marley vikings system sucker king allison marshall 1979 098765 qwerty12 hummer adrian 1985 vfhbyf sandman rocky leslie antonio 98765432 4321 softball passion mnbvcxz bastard passport horney rascal howard franklin bigred assman alexander homer redrum jupiter claudia 55555555 141414 zaq12wsx shit patches nigger cunt raider infinity andre 54321 galore college russia kawasaki bishop 77777777 vladimir money1 freeuser wildcats francis disney budlight brittany 1994 00000000 sweet oksana honda domino bulldogs brutus swordfis norman monday jimmy ironman ford fantasy 9999 7654321 PASSWORD hentai duncan cougar 1977 jeffrey house dancer brooke timothy super marines justice digger connor patriots karina 202020 molly everton tinker alicia rasdzv3 poop pearljam stinky naughty colorado 123123a water test123 ncc1701d motorola ireland asdfg slut matt houston boogie zombie accord vision bradley reggie kermit froggy ducati avalon 6666 9379992 sarah saints logitech chopper 852456 simpson madonna juventus claire 159951 zachary yfnfif wolverin warcraft hello123 extreme penis peekaboo fireman eugene brenda 123654789 russell panthers georgia smith skyline jesus elizabet spiderma smooth pirate empire bullet 8888 virginia valentin psycho predator arizona 134679 mitchell alyssa vegeta titanic christ goblue fylhtq wolf mmmmmm kirill indian hiphop baxter awesome people danger roland mookie 741852963 1111111111 dreamer bambam arnold 1981 skipper serega rolltide elvis changeme simon 1q2w3e lovelove fktrcfylh denver tommy mine loverboy hobbes happy1 alison nemesis chevelle cardinal burton wanker picard 151515 tweety michael1 147852369 12312 xxxx windows turkey 456789 1974 vfrcbv sublime 1975 galina bobby newport manutd daddy american alexandr 1966 victory rooster qqq111 madmax electric bigcock a1b2c3 wolfpack spring phpbb lalala suckme spiderman eric darkside classic raptor 123456789q hendrix 1982 wombat avatar alpha zxc123 crazy hard england brazil 1978 01011980 wildcat polina freepass carrie 99999999 qaz123 holiday fyfcnfcbz brother taurus shaggy raymond maksim gundam admin vagina pretty pickle good chronic alabama airplane 22222222 1976 1029384756 01011 time sports ronaldo pandora cheyenne caesar billybob bigman 1968 124578 snowman lawrence kenneth horse france bondage perfect kristen devils alpha1 pussycat kodiak flowers 1973 01012000 leather amber gracie chocolat bubba1 catch22 business 2323 1983 cjkysirj 1972 123qweasd ytrewq wolves stingray ssssss serenity ronald greenday 135790 010101 tiger1 sunset charlie1 berlin bbbbbb 171717 panzer lincoln katana firebird blizzard a1b2c3d4 white sterling redhead password123 candy anna 142536 sasha pyramid outlaw hercules garcia 454545 trevor teens maria kramer girl popeye pontiac hardon dude aaaaa 323232 tarheels honey cobra buddy1 remember lickme detroit clinton basketball zeppelin whynot swimming strike service pavilion michele engineer dodgers britney bobafett adam 741852 21122112 xxxxx robbie miranda 456123 future darkstar icecream connie 1970 jones hellfire fisher fireball apache fuckit blonde bigmac abcd morris angel1 666999 321321 simone rockstar flash defender 1967 wallace trooper oscar norton casino cancer beauty weasel savage raven harvey bowling 246810 wutang theone swordfish stewart airforce abcdefgh nipples nastya jenny hacker 753951 amateur viktor srinivas maxima lennon freddie bluebird qazqaz presario pimp packard mouse looking lesbian jeff cheryl 2001 wrangler sandy machine lights eatme control tattoo precious harrison duke beach tornado tanner goldfish catfish openup manager 1971 street Soso123aljg roscoe paris natali light julian jerry dilbert dbrnjhbz chris1 atlanta xfiles thailand sailor pussies pervert lucifer longhorn enjoy dragons young target elaine dustin 123qweasdzxc student madman lisa integra wordpass prelude newton lolita ladies hawkeye corona bubble 31415926 trigger spike katie iloveu herman design cannon 999999999 video stealth shooter nfnmzyf hottie browns 314159 trucks malibu bruins bobcat barbie 1964 orlando letmein1 freaky foobar cthutq baller unicorn scully pussy1 potter cookies pppppp philip gogogo elena country assassin 1010 zaqwsx testtest peewee moose microsoft teacher sweety stefan stacey shotgun random laura hooker dfvgbh devildog chipper athena winnie valentina pegasus kristin fetish butterfly woody swinger seattle lonewolf joker booty babydoll atlantis tony powers polaris montreal angelina 77777 tickle regina pepsi gizmo express dollar squirt shamrock knicks hotstuff balls transam stinger smiley ryan redneck mistress hjvfirf cessna bunny toshiba single piglet fucked father deftones coyote castle cadillac blaster valerie samurai oicu812 lindsay jasmin james1 ficken blahblah birthday 1234abcd 01011990 sunday manson flipper asdfghj 181818 wicked great daisy babes skeeter reaper maddie cavalier veronika trucker qazwsx123 mustang1 goldberg escort 12345678910 wolfgang rocks mylove mememe lancer ibanez travel sugar snake sister siemens savannah minnie leonardo basketba 1963 trumpet texas rocky1 galaxy cristina aardvark shelly hotsex goldie fatboy benson 321654 141627 sweetpea ronnie indigo 13131313 spartan roberto hesoyam freeman freedom1 fredfred pizza manchester lestat kathleen hamilton erotic blabla 22222 1995 skater pencil passwor larisa hornet hamlet gambit fuckyou2 alfred 456456 sweetie marino lollol 565656 techno special renegade insane indiana farmer drpepper blondie bigboobs 272727 1a2b3c valera storm seven rose nick mister karate casey 1qaz2wsx3edc 1478963 maiden julie curtis colors christia buckeyes 13579 0123456789 toronto stephani pioneer kissme jungle jerome holland harry garden enterpri dragon1 diamonds chrissy bigone 343434 wonder wetpussy subaru smitty racecar pascal morpheus joanne irina indians impala hamster charger change bigfoot babylon 66666666 timber redman pornstar bernie tomtom thuglife millie buckeye aaron virgin tristan stormy rusty pierre napoleon monkey1 highland chiefs chandler catdog aurora 1965 trfnthbyf sampson nipple dudley cream consumer burger brandi welcome1 triumph joejoe hunting dirty caserta brown aragorn 363636 mariah element chichi 2121 123qwe123 wrinkle1 smoke omega monika leonard justme hobbit gloria doggy chicks bass audrey 951753 51505150 11235813 sakura philips griffin butterfl artist 66666 island goforit emerald elizabeth anakin watson poison none italia callie bobbob autumn andreas 123 sherlock q12345 pitbull marathon kelsey inside german blackie access14 123asd zipper overlord nadine marie basket trombone stones sammie nugget naked kaiser isabelle huskers bomber barcelona babylon5 babe alpine weed ultimate pebbles nicolas marion loser linda eddie wesley warlock tyler goddess fatcat energy david1 bassman yankees1 whore trojan trixie superfly kkkkkk ybrbnf warren sophia sidney pussys nicola campbell vfvjxrf singer shirley qawsed paladin martha karen help harold geronimo forget concrete 191919 westham soldier q1w2e3r4t5y6 poiuyt nikki mario juice jessica1 global dodger 123454321 webster titans tintin tarzan sexual sammy1 portugal onelove marcel manuel madness jjjjjj holly christy 424242 yvonne sundance sex4me pleasure logan danny wwwwww truck spartak smile michel history Exigen 65432 1234321 sherry sherman seminole rommel network ladybug isabella holden harris germany fktrctq cotton angelo 14789632 sergio qazxswedc moon jesus1 trunks snakes sluts kingkong bluesky archie adgjmptw 911911 112358 sunny suck snatch planet panama ncc1701e mongoose head hansolo desire alejandr 1123581321 whiskey waters teen party martina margaret january connect bluemoon bianca andrei 5555555 smiles nolimit long assass abigail 555666 yomama rocker plastic katrina ghbdtnbr ferret emily bonehead blessed beagle asasas abgrtyu sticky olga japan jamaica home hector dddddd 1961 turbo stallion personal peace movie morrison joanna geheim finger cactus 7895123 susan super123 spyder mission anything aleksandr zxcvb shalom rhbcnbyf pickles passat natalia moomoo jumper inferno dietcoke cumming cooldude chuck christop million lollipop fernando christian blue22 bernard apple1 unreal spunky ripper open niners letmein2 flatron faster deedee bertha april 4128 01012010 werewolf rubber punkrock orion mulder missy larry giovanni gggggg cdtnkfyf yoyoyo tottenha shaved newman lindsey joey hongkong freak daniela camera brianna blackcat a1234567 1q1q1q zzzzzzzz stars pentium patton jamie hollywoo florence biscuit beetle andy always speed sailing phillip legion gn56gn56 909090 martini dream darren clifford 2002 stocking solomon silvia pirates office monitor monique milton matthew1 maniac loulou jackoff immortal fossil dodge delta 44444444 121314 sylvia sprite shadow1 salmon diana shasta patriot palmer oxford nylons molly1 irish holmes curious asdzxc 1999 makaveli kiki kennedy groovy foster drizzt twister snapper sebastia philly pacific jersey ilovesex dominic charlott carrot anthony1 africa 111222333 sharks serena satan666 maxmax maurice jacob gerald cosmos columbia colleen cjkywt cantona brooks 99999 787878 rodney nasty keeper infantry frog french eternity dillon coolio condor anton waterloo velvet vanhalen teddy skywalke sheila sesame seinfeld funtime 012345 standard squirrel qazwsxed ninja kingdom grendel ghost fuckfuck damien crimson boeing bird biggie 090909 zaq123 wolverine wolfman trains sweets sunrise maxine legolas jericho isabel foxtrot anal shogun search robinson rfrfirf ravens privet penny musicman memphis megadeth dogs butt brownie oldman graham grace 505050 verbatim support safety review newlife muscle herbert colt45 bottom 2525 1q2w3e4r5t6y 1960 159159 western twilight thanks suzanne potato pikachu murray master1 marlin gilbert getsome fuckyou1 dima denis 789789 456852 stone stardust seven7 peanuts obiwan mollie licker kansas frosty ball 262626 tarheel showtime roman markus maestro lobster darwin cindy chubby 2468 147896325 tanker surfing skittles showme shaney14 qwerty12345 magic1 goblin fusion blades banshee alberto 123321123 123098 powder malcolm intrepid garrett delete chaos bruno 1701 tequila short sandiego python punisher newpass iverson clayton amadeus 1234567a stimpy sooners preston poopie photos neptune mirage harmony gold fighter dingdong cats whitney sucks slick rick ricardo princes liquid helena daytona clover blues anubis 1996 192837465 starcraft roxanne pepsi1 mushroom eatshit dagger cracker capital brendan blackdog 25802580 strider slapshot porter pink jason1 hershey gothic flight ekaterina cody buffy boss bananas aaaaaaa 123698745 1234512345 tracey miami kolobok danni chargers cccccc blue123 bigguy 33333333 0.0.000 warriors walnut raistlin ping miguel latino griffey green1 gangster felix engine doodle coltrane byteme buck asdf123 123456z 0007 vertigo tacobell shark portland penelope osiris nymets nookie mary lucky7 lucas lester ledzep gorilla coco bugger bruce blood bentley battle 1a2b3c4d 19841984 12369874 weezer turner thegame stranger sally Mailcreated5240 knights halflife ffffff dorothy dookie damian 258456 women trance qwerasdf playtime paradox monroe kangaroo henry dumbass dublin charly butler brasil blade blackman bender baggins wisdom tazman swallow stuart scruffy phoebe panasonic Michael masters ghjcnj firefly derrick christine beautiful auburn archer aliens 161616 1122 woody1 wheels test1 spanking robin redred racerx postal parrot nimrod meridian madrid lonestar kittycat hell goodluck gangsta formula devil cassidy camille buttons bonjour bingo barcelon allen 98765 898989 303030 2020 0000000 tttttt tamara scoobydo samsam rjntyjr richie qwertz megaman luther jazz crusader bollox 123qaz 12312312 102938 window sprint sinner sadie rulez quality pooper pass123 oakland misty lvbnhbq lady hannibal guardian grizzly fuckface finish discover collins catalina carson black1 bang annie 123987 1122334455 wookie volume tina rockon qwer molson marco californ angelica 2424 world william1 stonecol shemale shazam picasso oracle moscow luke lorenzo kitkat johnjohn janice gerard flames duck dark celica 445566 234567 yourmom topper stevie septembe scarlett santiago milano lowrider loving incubus dogdog anastasia 1962 123zxc vacation tempest sithlord scarlet rebels ragnarok prodigy mobile keyboard golfing english carlo anime 545454 19921992 11112222 vfhecz sobaka shiloh penguins nuttertools mystery lorraine llllll lawyer kiss jeep gizmodo elwood dkflbvbh 987456 6751520 12121 titleist tardis tacoma smoker shaman rootbeer magnolia julia juan hoover gotcha dodgeram creampie buffett bridge aspirine 456654 socrates photo parola nopass megan lucy kenwood kenny imagine forgot cynthia blondes ashton aezakmi 1234567q viper1 terry sabine redalert qqqqqqqq munchkin monkeys mersedes melvin mallard lizzie imperial honda1 gremlin gillian elliott defiant dadada cooler bond blueeyes birdman bigballs analsex 753159 zaq1xsw2 xanadu weather violet sergei sebastian romeo research putter oooooo national lexmark hotboy greg garbage colombia chucky carpet bobo bobbie assfuck 88888 01012001 smokin shaolin roger rammstein pussy69 katerina hearts frogger freckles dogg dixie claude caliente amazon abcde 1221 wright willis spidey sleepy sirius santos rrrrrr randy picture payton mason dusty director celeste broken trebor sheena qazwsxedcrfv polo oblivion mustangs margarita letsgo josh jimbob jimbo janine jackal iforgot hallo fatass deadhead abc12 zxcv1234 willy stud slappy roberts rescue porkchop noodles nellie mypass mikey marvel laurie grateful fuck_inside formula1 Dragon cxfcnmt bridget aussie asterix a1s2d3f4 23232323 123321q veritas spankme shopping roller rogers queen peterpan palace melinda martinez lonely kristi justdoit goodtime frances camel beckham atomic alexandra active 223344 vanilla thankyou springer sommer Software sapphire richmond printer ohyeah massive lemons kingston granny funfun evelyn donnie deanna brucelee bosco aggies 313131 wayne thunder1 throat temple smudge qqqq qawsedrf plymouth pacman myself mariners israel hitler heather1 faith Exigent clancy chelsea1 353535 282828 123456qwerty tobias tatyana stuff spectrum sooner shitty sasha1 pooh pineappl mandy labrador kisses katrin kasper kaktus harder eduard dylan dead chloe astros 1234567890q 10101010 stephanie satan hudson commando bones bangkok amsterdam 1959 webmaster valley space southern rusty1 punkin napass marian magnus lesbians krishna hungry hhhhhh fuckers fletcher content account 906090 thompson simba scream q1q1q1 primus Passw0rd mature ivanov husker homerun esther ernest champs celtics candyman bush boner asian aquarius 33333 zxcv starfish pics peugeot painter monopoly lick infiniti goodbye gangbang fatman darling celine camelot boat blackjac barkley area51 8J4yE3Uz 789654 19871987 0000000000 vader shelley scrappy sarah1 sailboat richard1 moloko method mama kyle kicker keith judith john316 horndog godsmack flyboy emmanuel drago cosworth blake 19891989 writer usa123 topdog timmy speaker rosemary pancho night melody lightnin life hidden gator farside falcons desert chevrole catherin carolyn bowler anders 666777 369369 yesyes sabbath qwerty123456 power1 pete oscar1 ludwig jammer frontier fallen dance bryan asshole1 amber1 aaa111 123457 01011991 terror telefon strong spartans sara odessa luckydog frank1 elijah chang center bull blacks 15426378 132435 vivian tanya swingers stick snuggles sanchez redbull reality qwertyuio qwert123 mandingo ihateyou hayden goose franco forrest double carol bohica bell beefcake beatrice avenger andrew1 anarchy 963852 1366613 111111111 whocares scooter1 rbhbkk matilda labtec kevin1 jojo jesse hermes fitness doberman dawg clitoris camels 5555555555 1957 vulcan vectra topcat theking skiing nokia muppet moocow leopard kelley ivan grover gjkbyf filter elvis1 delta1 dannyboy conrad children catcat bossman bacon amelia alice 2222222 viktoria valhalla tricky terminator soccer1 ramona puppy popopo oklahoma ncc1701a mystic loveit looker latin laptop laguna keystone iguana herbie cupcake clarence bunghole blacky bennett bart 19751975 12332 000007 vette trojans today romashka puppies possum pa55word oakley moneys kingpin golfball funny doughboy dalton crash charlotte carlton breeze billie beast achilles tatiana studio sterlin plumber patrick1 miles kotenok homers gbpltw gateway1 franky durango drake deeznuts cowboys1 ccbill brando 9876543210 zzzz zxczxc vkontakte tyrone skinny rookie qwqwqw phillies lespaul juliet jeremiah igor homer1 dilligaf caitlin budman atlantic 989898 362436 19851985 vfrcbvrf verona technics svetik stripper soleil september pinkfloy noodle metal maynard maryland kentucky hastings gang frederic engage eileen butthole bone azsxdc agent007 474747 19911991 01011985 triton tractor somethin snow shane sassy sabina russian porsche9 pistol justine hurrican gopher deadman cutter coolman command chase california boris bicycle bethany bearbear babyboy 73501505 123456k zvezda vortex vipers tuesday traffic toto star69 server ready rafael omega1 nathalie microlab killme jrcfyf gizmo1 function freaks flamingo enterprise eleven doobie deskjet cuddles church breast 19941994 19781978 1225 01011970 vladik unknown truelove sweden striker stoner sony SaUn ranger1 qqqqq pauline nebraska meatball marilyn jethro hammers gustav escape elliot dogman chair brothers boots blow bella belinda babies 1414 titties syracuse river polska pilot oilers nofear military macdaddy hawk diamond1 dddd danila central annette 128500 zxcasd warhammer universe splash smut sentinel rayray randall Password1 panda nevada mighty meghan mayday manchest madden kamikaze jennie iloveyo hustler hunter1 horny1 handsome dthjybrf designer demon cheers cash cancel blueblue bigger australia asdfjkl 321654987 1qaz1qaz 1955 1234qwe 01011981 zaphod ultima tolkien Thomas thekid tdutybq summit select saint rockets rhonda retard rebel ralph poncho pokemon1 play pantyhos nina momoney market lickit leader kong jenna jayjay javier eatpussy dracula dawson daniil cartoon capone bubbas 789123 19861986 01011986 zxzxzx wendy tree superstar super1 ssssssss sonic sinatra scottie sasasa rush robert1 rjirfrgbde reagan meatloaf lifetime jimmy1 jamesbon houses hilton gofish charmed bowser betty 525252 123456789z 1066 woofwoof Turkey50 santana rugby rfnthbyf miracle mailman lansing kathryn Jennifer giant front242 firefox check boxing bogdan bizkit azamat apollo13 alan zidane tracy tinman terminal starbuck redhot oregon memory lewis lancelot illini grandma govols gordon24 giorgi feet fatima crunch creamy coke cabbage bryant brandon1 bigmoney azsxdcfv 3333333 321123 warlord station sayang rotten rightnow mojo models maradona lololo lionking jarhead hehehe gary fast exodus crazybab conner charlton catman casey1 bonita arjay 19931993 19901990 1001 100000 sticks poiuytrewq peters passwort orioles oranges marissa japanese holyshit hohoho gogo fabian donna cutlass cthulhu chewie chacha bradford bigtime aikido 4runner 21212121 150781 wildfire utopia sport sexygirl rereirf reebok raven1 poontang poodle movies microsof grumpy eeyore down dong chocolate chickens butch arsenal1 adult adriana 19831983 zzzzz volley tootsie sparkle software sexx scotch science rovers nnnnnn mellon legacy julius helen happyday fubar danie cancun br0d3r beverly beaner aberdeen 44444 19951995 13243546 123456aa wilbur treasure tomato theodore shania raiders1 natural kume kathy hamburg gretchen frisco ericsson daddy1 cosmo condom comics coconut cocks Check camilla bikini albatros 1Passwor 1958 1919 143143 0.0.0.000 zxcasdqwe zaqxsw whisper vfvekz tyler1 Sojdlg123aljg sixers sexsexsex rfhbyf profit okokok nancy mikemike michaela memorex marlene kristy jose jackson1 hope hailey fugazi fright figaro excalibu elvira dildo denali cruise cooter cheng candle bitch1 attack armani anhyeuem 78945612 222333 zenith walleye tsunami trinidad thomas1 temp tammy sultan steve1 slacker selena samiam revenge pooppoop pillow nobody kitty1 killer1 jojojo huskies greens greenbay greatone fuckin fortuna fordf150 first fashion fart emerson davis cloud9 china boob applepie alien 963852741 321456 292929 1998 1956 18436572 tasha stocks rustam rfrnec piccolo orgasm milana marisa marcos malaka lisalisa kelly1 hithere harley1 hardrock flying fernand dinosaur corrado coleman clapton chief bloody anfield 636363 420247 332211 voyeur toby texas1 surf steele running rastaman pa55w0rd oleg number1 maxell madeline keywest junebug ingrid hollywood hellyeah hayley goku felicia eeeeee dicks dfkthbz dana daisy1 columbus charli bonsai billy1 aspire 9999999 987987 50cent 000001 xxxxxxx wolfie viagra vfksirf vernon tang swimmer subway stolen sparta slutty skywalker sean sausage rockhard ricky positive nyjets miriam melissa1 krista kipper kcj9wx5n jedi jazzman hyperion happy123 gotohell garage football1 fingers february faggot easy dragoon crazy1 clemson chanel canon bootie balloon abc12345 609609609 456321 404040 162534 yosemite slider shado sandro roadkill quincy pedro mayhem lion knopka kingfish jerkoff hopper everest ddddddd damnit cunts chevy1 cheetah chaser billyboy bigbird bbbb 789987 1qa2ws3ed 1954 135246 123789456 122333 1000 050505 wibble valeria tunafish trident thor tekken tara starship slave saratoga romance robotech rich rasputin rangers1 powell poppop passwords p0015123 nwo4life murder milena midget megapass lucky13 lolipop koshka kenworth jonjon jenny1 irish1 hedgehog guiness gmoney ghetto fortune emily1 duster ding davidson davids dammit dale crysis bogart anaconda alibaba airbus 7753191 515151 20102010 200000 123123q 12131415 10203 work wood vladislav vfczyz tundra Translator torres splinter spears richards rachael pussie phoenix1 pearl monty lolo lkjhgf leelee karolina johanna jensen helloo harper hal9000 fletch feather fang dfkthf depeche barsik 789789789 757575 727272 zorro xtreme woman vitalik vermont train theboss sword shearer sanders railroad qwer123 pupsik pornos pippen pingpong nikola nguyen music1 magicman killbill kickass kenshin katie1 juggalo jayhawk java grapes fritz drew divine cyclops critter coucou cecilia bristol bigsexy allsop 9876 1230 01011989 wrestlin twisted trout tommyboy stefano song skydive sherwood passpass pass1234 onlyme malina majestic macross lillian heart guest gabrie fuckthis freeporn dinamo deborah crawford clipper city better bears bangbang asdasdasd artemis angie admiral 2003 020202 yousuck xbox360 werner vector usmc umbrella tool strange sparks spank smelly small salvador sabres rupert ramses presto pompey operator nudist ne1469 minime matador love69 kendall jordan1 jeanette hooter hansen gunners gonzo gggggggg fktrcfylhf facial deepthroat daniel1 dang cruiser cinnamon cigars chico chester1 carl caramel calico broadway batman1 baddog 778899 2128506 123456r 0420 01011988 z1x2c3 wassup wally vh5150 underdog thesims thecat sunnyday snoopdog sandy1 pooter multiplelo magick library kungfu kirsten kimber jean jasmine1 hotshot gringo fowler emma duchess damage cyclone Computer chong chemical chainsaw caveman catherine carrera canadian buster1 brighton back australi animals alliance albion 969696 555777 19721972 19691969 1024 trisha theresa supersta steph static snowboar sex123 scratch retired rambler r2d2c3po quantum passme over newbie mybaby musica misfit mechanic mattie mathew mamapapa looser jabroni isaiah heyhey hank hang golfgolf ghjcnjnfr frozen forfun fffff downtown coolguy cohiba christopher chivas chicken1 bullseye boys bottle bob123 blueboy believe becky beanie 20002000 yzerman west village vietnam trader summer1 stereo spurs solnce smegma skorpion saturday samara safari renault rctybz peterson paper meredith marc louis lkjhgfdsa ktyjxrf kill kids jjjj ivanova hotred goalie fishes eastside cypress cyber credit brad blackhaw beastie banker backdoor again 192837 112211 westwood venus steeler spawn sneakers snapple snake1 sims sharky sexxxx seeker scania sapper route66 Robert q123456 Passwor1 mnbvcx mirror maureen marino13 jamesbond jade horizon haha getmoney flounder fiesta europa direct dean compute chrono chad boomboom bobby1 bing beerbeer apple123 andres 8888888 777888 333666 1357 12345z 030303 01011987 01011984 wolf359 whitey undertaker topher tommy1 tabitha stroke staples sinclair silence scout scanner samsung1 rain poetry pisces phil peter1 packer outkast nike moneyman mmmmmmmm ming marianne magpie love123 kahuna jokers jjjjjjjj groucho goodman gargoyle fuckher florian federico droopy dorian donuts ddddd cinder buttman benny barry amsterda alfa 656565 1x2zkg8w 19881988 19741974 zerocool walrus walmart vfvfgfgf user typhoon test1234 studly Shadow sexy69 sadie1 rtyuehe rosie qwert1 nipper maximum klingon jess idontknow heidi hahahaha gggg fucku2 floppy flash1 fghtkm erotica erik doodoo dharma deniska deacon daphne daewoo dada charley cambiami bimmer bike bigbear alucard absolut a123456789 4121 19731973 070707 03082006 02071986 vfhufhbnf sinbad secret1 second seamus renee redfish rabota pudding pppppppp patty paint ocean number nature motherlode micron maxx massimo losers lokomotiv ling kristine kostya korn goldstar gegcbr floyd fallout dawn custom christina chrisbln button bonkers bogey belle bbbbb barber audia4 america1 abraham 585858 414141 336699 20012001 12345678q 0123 whitesox whatsup usnavy tuan titty titanium thursday thirteen tazmania steel starfire sparrow skidoo senior reading qwerqwer qazwsx12 peyton panasoni paintbal newcastl marius italian hotpussy holly1 goliath giuseppe frodo fresh buckshot bounce babyblue attitude answer 90210 575757 10203040 1012 01011910 ybrjkfq wasser tyson Superman sunflowe steam ssss sound solution snoop shou shawn sasuke rules royals rivers respect poppy phillips olivier moose1 mondeo mmmm knickers hoosier greece grant godfather freeze europe erica doogie danzig dalejr contact clarinet champ briana bluedog backup assholes allmine aaliyah 12345679 100100 zigzag whisky weaver truman tomorrow tight theend start southpark sersolution roberta rhfcjnrf qwerty1234 quartz premier paintball montgom240 mommy mittens micheal maggot loco laurel lamont karma journey johannes intruder insert hairy hacked groove gesperrt francois focus felipe eternal edwards doug dollars dkflbckfd dfktynbyf demons deejay cubbies christie celeron cat123 carbon callaway bucket albina 2004 19821982 19811981 1515 12qw34er 123qwerty 123aaa 10101 1007 080808 zeus warthog tights simona shun salamander resident reefer racer quattro public poseidon pianoman nonono michell mellow luis jillian havefun gunnar goofy futbol fucku eduardo diehard dian chuckles carla carina avalanch artur allstar abc1234 abby 4545 1q2w3e4r5 125125 123451 ziggy yumyum working what wang wagner volvo ufkbyf twinkle susanne superman1 sunshin strip searay rockford radio qwertyqwerty proxy prophet ou8122 oasis mylife monke monaco meowmeow meathead Master leanne kang joyjoy joker1 filthy emmitt craig cornell changed cbr600 builder budweise boobie bobobo biggles bigass bertie amanda1 a1s2d3 784512 767676 235689 1953 19411945 14725836 11223 01091989 01011992 zero vegas twins turbo1 triangle thongs thanatos sting starman spike1 smokes shai sexyman sex scuba runescape phish pepper1 padres nitram nickel napster lord jewels jeanne gretzky great1 gladiator crjhgbjy chuang chou blossom bean barefoot alina 787898 567890 5551212 25252525 02071982 zxcvbnm1 zhong woohoo welder viewsonic venice usarmy trial traveler together team tango swords starter sputnik spongebob slinky rover ripken rasta prissy pinhead papa pants original mustard more mohammed mian medicine mazafaka lance juliette james007 hawkeyes goodboy gong footbal feng derek deeznutz dante combat cicero chun cerberus beretta bengals beaches 3232 135792468 12345qwe 01234567 01011975 zxasqw12 xxx123 xander will watcher thedog terrapin stoney stacy something shang secure rooney rodman redwing quan pony pobeda pissing philippe overkill monalisa mishka lions lionel leonid krystal kosmos jessic jane illusion hoosiers hayabusa greene gfhjkm123 games francesc enter1 confused cobra1 clevelan cedric carole busted bonbon barrett banane badgirl antoine 7779311 311311 2345 187187 123456s 123456654321 1005 0987 01011993 zippy zhei vinnie tttttttt stunner stoned smoking smeghead sacred redwood Pussy1 moonlight momomo mimi megatron massage looney johnboy janet jagger jacob1 hurley hong hihihi helmet heckfy hambone gollum gaston f**k death1 Charlie chao cfitymrf casanova brent boricua blackjack blablabla bigmike bermuda bbbbbbbb bayern amazing aleksey 717171 12301230 zheng yoyo wildman tracker syncmaster sascha rhiannon reader queens qing purdue pool poochie poker petra person orchid nuts nice lola lightning leng lang lambert kashmir jill idiot honey1 fisting fester eraser diao delphi dddddddd cubswin cong claudio clark chip buzzard buzz butts brewster bravo bookworm blessing benfica because babybaby aleksandra 6666666 1997 19961996 19791979 1717 1213 02091987 02021987 xiao wild valencia trapper tongue thegreat sancho really rainman piper peng peach passwd packers1 newpass6 neng mouse1 motley morning midway Michelle miao maste marin kaylee justin1 hokies health glory five dutchess dogfood comet clouds cloud charles1 buddah bacardi astrid alphabet adams 19801980 147369 12qwas 02081988 02051986 02041986 02011985 01011977 xuan vedder valeri teng stumpy squash snapon site ruan roadrunn rjycnfynby rhtdtlrj rambo pizzas paula novell mortgage misha menace maxim lori kool hanna gsxr750 goldwing frisky famous dodge1 dbrnjh christmas cheese1 century candice booker beamer assword army angus andromeda adrienne 676767 543210 2010 1369 12345678a 12011987 02101985 02031986 02021988 zhuang zhou wrestling tinkerbell thumbs thedude teddybea sssss sonics sinister shannon1 satana sang salomon remote qazzaq playing piao pacers onetime nong nikolay motherfucker mortimer misery madison1 luan lovesex look Jessica handyman hampton gromit ghostrider doghouse deluxe clown chunky chuai cgfhnfr brewer boxster balloons adults a1a1a1 794613 654123 24682468 2005 1492 1020 1017 02061985 02011987 ***** zhun ying yang windsor wedding wareagle svoboda supreme stalin sponge simon1 roadking ripple realmadrid qiao PolniyPizdec0211 pissoff peacock norway nokia6300 ninjas misty1 medusa medical maryann marika madina logan1 lilly laser killers jiang jaybird jammin intel idontkno huai harry1 goaway gameover dino destroy deng collin claymore chicago1 cheater chai bunny1 blackbir bigbutt bcfields athens antoni abcd123 686868 369963 1357924680 12qw12 1236987 111333 02091986 02021986 01011983 000111 zhuai yoda xiang wrestle whiskers valkyrie toon tong ting talisman starcraf sporting spaceman southpar smiths skate shell seng saleen ruby reng redline rancid pepe optimus nova mohamed meister marcia lipstick kittykat jktymrf jenn jayden inuyasha higgins guai gonavy face eureka dutch darkman courage cocaine circus cheeks camper br549 bagira babyface 7uGd5HIp2J 5050 1qaz2ws 123321a 02081987 02081984 02061986 02021984 01011982 zhai xiong willia vvvvvv venera unique tian sveta strength stories squall secrets seahawks sauron ripley riley recovery qweqweqwe qiong puddin playstation pinky phone penny1 nude mitch milkman mermaid max123 maria1 lust loaded lighter lexus leavemealone just4me jiong jing jamie1 india hardcock gobucks gawker fytxrf fuzzy florida1 flexible eleanor dragonball doudou cinema checkers charlene ceng buffy1 brian1 beautifu baseball1 ashlee adonis adam12 434343 02031984 02021985 xxxpass toledo thedoors templar sullivan stanford shei sander rolling qqqqqqq pussey pothead pippin nimbus niao mustafa monte mollydog modena mmmmm michae meng mango mamama lynn love12 kissing keegan jockey illinois ib6ub9 hotbox hippie hill ghblehjr gamecube ferris diggler crow circle chuo chinook charity carmel caravan cannabis cameltoe buddie bright bitchass bert beowulf bartman asia armagedon ariana alexalex alenka ABC123 987456321 373737 2580 21031988 123qq123 12345t 1234567890a 123455 02081989 02011986 01020304 01011999 xyz123 xerxes wraith wishbone warning todd ticket three subzero shuang rong rider quest qiang pppp pian petrov otto nuan ning myname matthews martine mandarin magical latinas lalalala kotaku jjjjj jeffery jameson iamgod hellos hassan Harley godfathe geng gabriela foryou ffffffff divorce darius chui breasts bluefish binladen bigtit anne alexia 2727 19771977 19761976 02061989 02041984 zhui zappa yfnfkmz weng tricia tottenham tiberius teddybear spinner spice spectre solo silverad silly shuo sherri samtron poland poiuy pickup pdtplf paloma ntktajy northern nasty1 musashi missy1 microphone meat manman lucille lotus letter kendra iomega hootie forward elite electron electra duan DRAGON dotcom dirtbike dianne desiree deadpool darrell cosmic common chrome cathy carpedie bilbo bella1 beemer bearcat bank ashley1 asdfzxcv amateurs allan absolute 50spanks 147963 120676 1123 02021983 zang virtual vampires vadim tulips sweet1 suan spread spanish some slapper skylar shiner sheng shanghai sanfran ramones property pheonix password2 pablo othello orange1 nuggets netscape ludmila lost liang kakashka kaitlyn iscool huang hillary high hhhh heater hawaiian guang grease gfhjkmgfhjkm gfhjkm1 fyutkbyf finance farley dogshit digital1 crack counter corsair company colonel claudi carolin caprice caligula bulls blackout beatle beans banzai banner artem 9562876 5656 1945 159632 15151515 123456qw 1234567891 02051983 02041983 02031987 02021989 z1x2c3v4 xing vSjasnel12 twenty toolman thing testpass stretch stonecold soulmate sonny snuffy shutup shuai shao rhino q2w3e4r5 polly poipoi pierce piano pavlov pang nicole1 millions marsha lineage2 liao lemon kuai keller jimmie jiao gregor ggggg game fuckyo fuckoff1 friendly fgtkmcby evan edgar dolores doitnow dfcbkbq criminal coldbeer chuckie chimera chan ccccc cccc cards capslock cang bullfrog bonjovi bobdylan beth berger barker balance badman bacchus babylove argentina annabell akira 646464 15975 1223 11221122 1022 02081986 02041988 02041987 02041982 02011988 zong zhang yummy yeahbaby vasilisa temp123 tank slim skyler silent sergeant reynolds qazwsx1 PUSSY pasword nomore noelle nicol newyork1 mullet monarch merlot mantis mancity magazine llllllll kinder kilroy katherine jayhawks jackpot ipswich hack fishing1 fight ebony dragon12 dog123 dipshit crusher chippy canyon bigbig bamboo athlon alisha abnormal a11111 2469 12365 1011 09876543 02101984 02081985 02071984 02011980 010180 01011979 zhuo zaraza wg8e3wjf triple tototo theater teddy1 syzygy susana sonoma slavik shitface sheba sexyboy screen salasana rufus Richard reds rebecca1 pussyman pringles preacher park oceans niang momo misfits mikey1 media manowar mack kayla jump jorda hondas hollow here heineken halifax gatorade gabriell ferrari1 fergie female eldorado eagles1 cygnus coolness colton ciccio cheech card boom blaze bhbirf BASEBALL barton 655321 1818 14141414 123465 1224 1211 111111a 02021982 zhao wings warner vsegda tripod tiao thunderb telephon tdutybz talon speedo specialk shepherd shadows samsun redbird race promise persik patience paranoid orient monster1 missouri mets mazda masamune martin1 marker march manning mamamama licking lesley laurence jezebel jetski hopeless hooper homeboy hole heynow forum foot ffff farscape estrella entropy eastwood dwight dragonba door dododo deutsch crystal1 corleone cobalt chopin chevrolet cattle carlitos buttercu butcher bushido buddyboy blond bingo1 becker baron augusta alex123 998877 24242424 12365478 02061988 02031985 ?????? zuan yfcntymrf wowwow winston1 vfibyf ventura titten tiburon thoma thelma stroker snooker smokie slippery shui shock seadoo sandwich records rang puffy piramida orion1 napoli nang mouth monkey12 millwall mexican meme maxxxx magician leon lala lakota jenkins jackson5 insomnia harvard HARLEY hardware giorgio ginger1 george1 gator1 fountain fastball exotic elizaveta dialog davide channel castro bunnies borussia asddsa andromed alfredo alejandro 7007 69696 4417 3131 258852 1952 147741 1234asdf 02081982 02051982 zzzzzzz zeng zalupa yong windsurf wildcard weird violin universal sunflower suicide strawberry stepan sphinx someone sassy1 romano reddevil raquel rachel1 pornporn polopolo pluto plasma pinkfloyd panther1 north milo maxime matteo malone major mail lulu ltybcrf lena lassie july jiggaman jelly islander inspiron hopeful heng hans green123 gore gooner goirish gadget freeway fergus eeeee diego dickie deep danny1 cuan cristian conover civic Buster bombers bird33 bigfish bigblue bian beng beacon barnes astro artemka annika anita Andrew 747474 484848 464646 369258 225588 1z2x3c 1a2s3d4f 123456qwe 02061980 02031982 02011984 zaqxswcde wrench washington violetta tuning trainer tootie store spurs1 sporty sowhat sophi smashing sleeper slave1 sexysexy seeking sam123 robotics rjhjktdf reckless pulsar project placebo paddle oooo nightmare nanook married linda1 lilian lazarus kuang knockers killkill keng katherin Jordan jellybea jayson iloveme hunt hothot homerj hhhhhhhh helene haggis goat ganesh gandalf1 fulham force dynasty drakon download doomsday dieter devil666 desmond darklord daemon dabears cramps cougars clowns classics citizen cigar chrysler carlito candace bruno1 browning brodie bolton biao barbados aubrey arlene arcadia amigo abstr 9293709b13 737373 4444444 4242 369852 20202020 1qa2ws 1Pussy 1947 1234560 1112 1000000 02091983 02061987 01081989 zephyr yugioh yjdsqgfhjkm woofer wanted volcom verizon tripper toaster tipper tigger1 tartar superb stiffy spock soprano snowboard sexxxy senator scrabble santafe sally1 sahara romero rhjrjlbk reload ramsey rainbow6 qazwsxedc123 poopy pharmacy obelix normal nevermind mordor mclaren mariposa mari manuela mallory magelan lovebug lips kokoko jakejake insanity iceberg hughes hookup hockey1 hamish graphics geoffrey firewall fandango ernie dottie doofus donovan domain digimon darryl darlene dancing county chloe1 chantal burrito bummer bubba69 brett bounty bigcat bessie basset augustus ashleigh 878787 3434 321321321 12051988 111qqq 1023 1013 05051987 02101989 02101987 02071987 02071980 02041985 titan thong sweetnes stanislav sssssss snappy shanti shanna shan script scorpio1 RuleZ rochelle rebel1 radiohea q1q2q3 puss pumpkins puffin onetwo oatmeal nutmeg ninja1 nichole mobydick marine1 mang lover1 longjohn lindros killjoy kfhbcf karen1 jingle jacques iverson3 istanbul iiiiii howdy hover hjccbz highheel happiness guitar1 ghosts georg geneva gamecock fraser faithful dundee dell creature creation corey concorde cleo cdtnbr carmex2 budapest bronze brains blue12 battery attila arrow anthrax aloha 383838 19711971 1948 134679852 123qw 123000 02091984 02091981 02091980 02061983 02041981 01011900 zhjckfd zazaza wingman windmill wifey webhompas watch thisisit tech submit stress spongebo silver1 senators scott1 sausages radical qwer12 ppppp pixies pineapple piazza patrice officer nygiants nikitos nigga nextel moses moonbeam mihail MICHAEL meagan marcello maksimka loveless lottie lollypop laurent latina kris kleopatra kkkk kirsty katarina kamila jets iiii icehouse hooligan gertrude fullmoon fuckinside fishin everett erin dynamite dupont dogcat dogboy diane corolla citadel buttfuck bulldog1 broker brittney boozer banger aviation almond aaron1 78945 616161 426hemi 333777 22041987 2008 20022002 153624 1121 111111q 05051985 02081977 02071988 02051988 02051987 02041979 zander wwww webmaste webber taylor1 taxman sucking stylus spoon spiker simmons sergi sairam royal ramrod radiohead popper platypus pippo pepito pavel monkeybo Michael1 master12 marty kjkszpj kidrock judy juanita joshua1 jacobs idunno icu812 hubert heritage guyver gunther Good123654 ghost1 getout gameboy format festival evolution epsilon enrico electro dynamo duckie drive dolphin1 ctrhtn cthtuf cobain club chilly charter celeb cccccccc caught cascade carnage bunker boxers boxer bombay bigboss bigben beerman baggio asdf12 arrows aptiva a1a2a3 a12345678 626262 26061987 1616 15051981 08031986 060606 02061984 02061982 02051989 02051984 02031981 woodland whiteout visa vanguard towers tiny tigger2 temppass super12 stop stevens softail sheriff robot reddwarf pussy123 praise pistons patric partner niceguy morgan1 model mars mariana manolo mankind lumber krusty kittens kirby june johann jared imation henry1 heat gobears forsaken Football fiction ferguson edison earnhard dwayne dogger diver delight dandan dalshe cross cottage coolcool coach camila callum busty british biology beta beardog baldwin alone albany airwolf 9876543 987123 7894561230 786786 535353 21031987 1949 13041988 1234qw 123456l 1215 111000 11051987 10011986 06061986 02091985 02021981 02021979 01031988 vjcrdf uranus tiger123 summer99 state starstar squeeze spikes snowflak slamdunk sinned shocker season santa sanity salome saiyan renata redrose queenie puppet popo playboy1 pecker paulie oliver1 ohshit norwich news namaste muscles mortal michael2 mephisto mandy1 magnet longbow llll living lithium komodo kkkkkkkk kjrjvjnbd killer12 kellie julie1 jarvis iloveyou2 holidays highway havana harvest harrypotter gorgeous giraffe garion frost fishman erika earth dusty1 dudedude demo deer concord colnago clit choice chillin bumper blam bitter bdsm basebal barron baker arturo annie1 andersen amerika aladin abbott 81fukkc 5678 135791 1002 02101986 02081983 02041989 02011989 01011978 zzzxxx zxcvbnm123 yyyyyy yuan yolanda winners welcom volkswag vera ursula ultra toffee toejam theatre switch superma Stone55 solitude sissy sharp scoobydoo romans roadster punk presiden pool6123 playstat pipeline pinball peepee paulina ozzy nutter nights niceass mypassword mydick milan medic mazdarx7 mason1 marlon mama123 lemonade krasotka koroleva karin jennife itsme isaac irishman hookem hewlett hawaii50 habibi guitars grande glacier gagging gabriel1 freefree francesco food flyfish fabric edward1 dolly destin delilah defense codered cobras climber cindy1 christma chipmunk chef brigitte bowwow bigblock bergkamp bearcats baba altima 74108520 45M2DO5BS 30051985 258258 24061986 22021989 21011989 20061988 1z2x3c4v 14061991 13041987 123456m 12021988 11081989 03041991 02071981 02031979 02021976 01061990 01011960 yvette yankees2 wireless werder wasted visual trust tiffany1 stratus steffi stasik starligh sigma rubble ROBERT register reflex redfox record qwerty7 premium prayer players pallmall nurses nikki1 nascar24 mudvayne moritz moreno moondog monsters micro mickey1 mckenzie mazda626 manila madcat louie loud krypton kitchen kisskiss kate jubilee impact Horny hellboy groups goten gonzalez gilles gidget gene gbhfvblf freebird federal fantasia dogbert deeper dayton comanche cocker choochoo chambers borabora bmw325 blast ballin asdfgh01 alissa alessandro airport abrakadabra 7777777777 635241 494949 420000 23456789 23041987 19701970 1951 18011987 172839 1235 123456789s 1125 1102 1031 07071987 02091989 02071989 02071983 02021973 02011981 01121986 01071986 0101 zodiac yogibear word water1 wasabi wapbbs wanderer vintage viktoriya varvara upyours undertak underground undead umpire tropical tiger2 threesom there sunfire sparky1 snoopy1 smart slowhand sheridan sensei savanna rudy redsox1 ramirez prowler postman porno1 pocket pelican nfytxrf nation mykids mygirl moskva mike123 Master1 marianna maggie1 maggi live landon lamer kissmyass keenan just4fun julien juicy JORDAN jimjim hornets hammond hallie glenn ghjcnjgfhjkm gasman FOOTBALL flanker fishhead firefire fidelio fatty excalibur enterme emilia ellie eeee diving dindom descent daniele dallas1 customer contest compass comfort comedy cocksuck close clay chriss chiara cameron1 calgary cabron bologna berkeley andyod22 alexey achtung 45678 3636 28041987 25081988 24011985 20111986 19651965 1941 19101987 19061987 1812 14111986 13031987 123ewq 123456123 12121990 112112 10071987 10031988 02101988 02081980 02021990 01091987 01041985 01011995 zebra zanzibar waffle training teenage sweetness sutton sushi suckers spam south sneaky sisters shinobi shibby sexy1 rockies presley president pizza1 piggy password12 olesya nitro motion milk medion markiz lovelife longdong lenny larry1 kirk johndeer jefferso james123 jackjack ijrjkfl hotone heroes gypsy foxy fishbone fischer fenway eddie1 eastern easter drummer1 Dragon1 Daniel coventry corndog compton chilli chase1 catwoman booster avenue armada 987321 818181 606060 5454 28021992 25800852 22011988 19971997 1776 17051988 14021985 13061986 12121985 11061985 10101986 10051987 10011990 09051945 08121986 04041991 03041986 02101983 02101981 02031989 02031980 01121988 wwwwwww virgil troy torpedo toilet tatarin survivor sundevil stubby straight spotty slater skip sheba1 runaway revolver qwerty11 qweasd123 parol paradigm older nudes nonenone moore mildred michaels lowell knock klaste junkie jimbo1 hotties hollie gryphon gravity grandpa ghjuhfvvf frogman freesex foreve felix1 fairlane everlast ethan eggman easton denmark deadly cyborg create corinne cisco chick chestnut bruiser broncos1 bobdole azazaz antelope anastasiya 456456456 415263 30041986 29071983 29051989 29011985 28021990 28011987 27061988 25121987 25031987 24680 22021986 21031990 20091991 20031987 196969 19681968 1946 17061988 16051989 16051987 1210 11051990 100500 08051990 05051989 04041988 02051980 02051976 02041980 02031977 02011983 01061986 01041988 01011994 0000007 zxcasdqwe123 washburn vfitymrf troll tranny tonight thecure studman spikey soccer12 soccer10 smirnoff slick1 skyhawk skinner shrimp shakira sekret seagull score sasha_007 rrrrrrrr ross rollins reptile razor qwert12345 pumpkin1 porsche1 playa notused noname123 newcastle never nana MUSTANG minerva megan1 marseille marjorie mamamia malachi lilith letmei lane lambda krissy kojak kimball keepout karachi kalina justus joel joe123 jerry1 irinka hurricane honolulu holycow hitachi highbury hhhhh hannah1 hall guess glass gilligan giggles flores fabie eeeeeeee dungeon drifter dogface dimas dentist death666 costello castor bronson brain bolitas boating benben baritone bailey1 badgers austin1 astra asimov asdqwe armand anthon amorcit 797979 4200 31011987 3030 30031988 3000gt 224466 22071986 21101986 21051991 20091988 2009 20051988 19661966 18091985 18061990 15101986 15051990 15011987 13121985 12qw12qw 1234123 1204 12031987 12031985 11121986 1025 1003 08081988 08031985 03031986 02101979 02071979 02071978 02051985 02051978 02051973 02041975 02041974 02031988 02011982 01031989 01011974 zoloto zippo wwwwwwww w_pass wildwood wildbill transit superior styles stryker string stream stefanie slugger skillet sidekick show shawna sf49ers Salsero rosario remingto redeye redbaron question quasar ppppppp popova physics papers palermo options mothers moonligh mischief ministry minemine messiah mentor megane mazda6 marti marble leroy laura1 lantern Kordell1 koko knuckles khan kerouac kelvin jorge joebob jewel iforget Hunter house1 horace hilary grand gordo glock georgie George fuckhead freefall films fantomas extra ellen elcamino doors diaper datsun coldplay clippers chandra carpente carman capricorn calimero boytoy boiler bluesman bluebell bitchy bigpimp bigbang biatch Baseball audi astral armstron angelika angel123 abcabc 999666 868686 3x7PxR 357357 30041987 27081990 26031988 258369 25091987 25041988 24111989 23021986 22041988 22031984 21051988 17011987 16121987 15021985 142857 14021986 13021990 12345qw 123456ru 1124 10101990 10041986 07091990 02051981 01031985 01021990 ****** zildjian yfnfkb yeah WP2003WP vitamin villa valentine trinitro torino tigge thewho thethe tbone swinging sonia sonata smoke1 sluggo sleep simba1 shamus sexxy sevens rober rfvfcenhf redhat quentin qazws pufunga7782 priest pizdec pigeon pebble palmtree oxygen nostromo nikolai mmmmmmm mahler lorena lopez lineage korova kokomo kinky kimmie kieran jsbach johngalt isabell impreza iloveyou1 iiiii huge fuck123 franc foxylady fishfish fearless evil entry enforcer emilie duffman ducks dominik david123 cutiepie coolcat cookie1 conway citroen chinese cheshire cherries chapman changes carver capricor book blueball blowfish benoit Beast1 aramis anchor 741963 654654 57chevy 5252 357159 345678 31031988 25091990 25011990 24111987 23031990 22061988 21011991 21011988 1942 19283746 19031985 19011989 18091986 17111985 16051988 15071987 145236 14081985 132456 13071984 1231 12081985 1201 11021985 10071988 09021988 05061990 02051972 02041978 02031983 01091985 01031984 010191 01012009 yamahar1 wormix whistler wertyu warez vjqgfhjkm versace universa taco sugar1 strawber stacie sprinter spencer1 sonyfuck smokey1 slimshady skibum series screamer sales roswell roses report rampage qwedsa q11111 program Princess petrova patrol papito papillon paco oooooooo mother1 mick Maverick marcius2 magneto macman luck lalakers lakeside krolik kings kille kernel kent junior1 jules jermaine jaguars honeybee hola highlander helper hejsan hate hardone gustavo grinch gratis goth glamour ghbywtccf ghbdtn123 elefant earthlink draven dmitriy dkflbr dimples cygnusx1 cold cococo clyde cleopatr choke chelse cecile casper1 carnival cardiff buddy123 bruce1 bootys bookie birddog bigbob bestbuy assasin arkansas anastasi alberta addict acmilan 7896321 30081984 258963 25101988 23051985 23041986 23021989 22121987 22091988 22071987 22021988 2006 20052005 19051987 15041988 15011985 14021990 14011986 13051987 13011988 13011987 12345s 12061988 12041988 12041986 11111q 11071988 11031988 10081989 08081986 07071990 07071977 05071984 04041983 03021986 02091988 02081976 02051977 02031978 01071987 01041987 01011976 zack zachary1 yoyoma wrestler weston wealth wallet vjkjrj vendetta twiggy twelve turnip tribal tommie tkbpfdtnf thecrow test12 terminat telephone synergy style spud smackdow slammer sexgod seabee schalke sanford sandrine salope rusty2 right repair referee ratman radar qwert40 qwe123qwe prozac portal polish Patrick passes otis oreo option opendoor nuclear navy nautilus nancy1 mustang6 murzik mopar monty1 Misfit99 mental medved marseill magpies magellan limited Letmein1 lemmein leedsutd larissa kikiki jumbo jonny jamess jackass1 install hounddog holes hetfield heidi1 harlem gymnast gtnhjdbx godlike glow gideon ghhh47hj7649 flip flame fkbyjxrf fenris excite espresso ernesto dontknow dogpound dinner diablo2 dejavu conan complete cole chocha chips chevys cayman breanna borders blue32 blanco bismillah biker bennie benito azazel ashle arianna argentin antonia alanis advent acura 858585 4040 333444 30041985 29071985 29061990 27071987 27061985 27041990 26031990 24031988 23051990 2211 22011986 21061986 20121989 20092009 20091986 20081991 20041988 20041986 1qwerty 19671967 1950 19121989 19061990 18101987 18051988 18041986 18021984 17101986 17061989 17041991 16021990 15071988 15071986 14101987 135798642 13061987 1234zxcv 12321 1214 12071989 1129 11121985 11061991 10121987 101101 10101985 10031987 100200 09041987 09031988 06041988 05071988 03081989 02071985 02071975 0123456 01051989 01041992 01041990 zarina woodie whiteboy white1 waterboy volkov vlad virus vikings1 viewsoni vbkfirf trans terefon swedish squeak spanner spanker sixpack seymour sexxx serpent samira roma rogue robocop robins real Qwerty1 qazxcv q2w3e4 punch pinky1 perry peppe penguin1 Password123 pain optimist onion noway nomad nine morton moonshin money12 modern mcdonald mario1 maple loveya love1 loretta lookout loki lllll llamas limewire konstantin k.lvbkf keisha jones1 jonathon johndoe johncena john123 janelle intercourse hugo hopkins harddick glasgow gladiato gambler galant gagged fortress factory expert emperor eight django dinara devo daniels crusty cowgirl clutch clarissa cevthrb ccccccc capetown candy1 camero camaross callisto butters bigpoppa bigones bigdawg best beater asgard angelus amigos amand alexandre 9999999999 8989 875421 30011985 29051985 2626 26061985 25111987 25071990 22081986 22061989 21061985 20082008 20021988 1a2s3d 19981998 16051985 15111988 15051985 15021990 147896 14041988 123567 12345qwerty 12121988 12051990 12051986 12041990 11091989 11051986 11051984 1008 10061986 0815 06081987 06021987 04041990 02081981 02061977 02041977 02031975 01121987 01061988 01031986 01021989 01021988 wolfpac wert vienna venture vehpbr vampir university tuna trucking trip trees transfer tower tophat tomahawk timosha timeout tenchi tabasco sunny1 suckmydick suburban stratfor steaua spiral simsim shadow12 screw schmidt rough rockie reilly reggae quebec private1 printing pentagon pearson peachy notebook noname nokian73 myrtle munch moron matthias mariya marijuan mandrake mamacita malice links lekker lback larkin ksusha kkkkk kestrel kayleigh inter insight hotgirls hoops hellokitty hallo123 gotmilk googoo funstuff fredrick firefigh finland fanny eggplant eating dogwood doggies dfktynby derparol data damon cvthnm cuervo coming clock cleopatra clarke cheddar cbr900rr carroll canucks buste bukkake boyboy bowman bimbo bighead bball barselona aspen asdqwe123 around aries americ almighty adgjmp addison absolutely aaasss 4ever 357951 29061989 28051987 27081986 25061985 25011986 24091986 24061988 24031990 21081987 21041992 20031991 2001112 19061985 18111987 18021988 17071989 17031987 16051990 15021986 14031988 14021987 14011989 1220 1205 120120 111999 111777 1115 1114 11011990 1027 10011983 09021989 07051990 06051986 05091988 05081988 04061986 04041985 03041980 02101976 02071976 02061976 02011975 01031983 zasada wyoming wendy1 washingt warrior1 vickie vader1 uuuuuu username tupac Trustno1 tinkerbe suckdick streets strap storm1 stinker sterva southpaw solaris sloppy sexylady sandie roofer rocknrol rico rfhnjirf QWERTY qqqqq1 punker progress platon Phoenix Phoeni peeper pastor paolo page obsidian nirvana1 nineinch nbvjatq navigator native money123 modelsne minimoni millenium max333 maveric matthe marriage marquis markie marines1 marijuana margie little1 lfybbk klizma kimkim kfgjxrf joshu jktxrf jennaj irishka irene ilove hunte htubcnhfwbz hottest heinrich happy2 hanson handball greedy goodie golfer1 gocubs gerrard gabber fktyrf facebook eskimo elway7 dylan1 dominion domingo dogbone default darkangel cumslut cumcum cricket1 coral coors chris123 charon challeng canuck call calibra buceta bubba123 bricks bozo blues1 bluejays berry beech awful april1 antonina antares another andrea1 amore alena aileen a1234 996633 556677 5329 5201314 3006 28051986 28021985 27031989 26021987 25101989 25061986 25041985 25011985 24061987 23021985 23011985 223322 22121986 22121983 22081983 22071989 22061987 22061941 22041986 22021985 21021985 2007 20031988 1qaz 199999 19101990 19071988 19071986 18061985 18051990 17071985 16111990 16061986 16011989 15081991 15051987 14071987 13031986 123qwer 1235789 123459 1227 1226 12101988 12081984 12071987 1200 11121987 11081987 11071985 11011991 1101 1004 08071987 08061987 05061986 04061991 03111987 03071987 02091976 02081979 02041976 02031973 02021991 02021980 02021971 zouzou yaya wxcvbn wolfen wives wingnut whatwhat Welcome1 wanking VQsaBLPzLa truth tracer trace theforce terrell sylveste susanna stephane stephan spoons spence sixty sheepdog services sawyer sandr saigon rudolf rodeo roadrunner rimmer ricard republic redskin Ranger ranch proton post pigpen peggy paris1 paramedi ou8123 nevets nazgul mizzou midnite metroid Matthew masterbate margarit loser1 lolol lloyd kronos kiteboy junk joyce jomama joemama ilikepie hung homework hattrick hardball guido goodgirl globus funky friendster flipflop flicks fender1 falcon1 f00tball evolutio dukeduke disco devon derf decker davies cucumber cnfybckfd clifton chiquita castillo cars capecod cafc91 brown1 brand bomb boater bledsoe bigdicks bbbbbbb barley barfly ballet azzer azert asians angelic ambers alcohol 6996 5424 393939 31121990 30121987 29121987 29111989 29081990 29081985 29051990 27272727 27091985 27031987 26031987 26031984 24051990 23061990 22061990 22041985 22031991 22021990 21111985 21041985 20021986 19071990 19051986 19011987 17171717 17061986 17041987 16101987 16031990 159357a 15091987 15081988 15071985 15011986 14101988 14071988 14051990 14021983 132465 13111990 12121987 12121982 12061986 12011989 11111987 11081990 10111986 10031991 09090909 08051987 08041986 05051990 04081987 04051988 03061987 03031993 03031988 02101980 02101977 02091977 02091975 02061979 02051975 01081990 01061987 01011971 wiseguy weed420 tosser toriamos toolbox toocool tomas thedon tender taekwondo starwar start1 sprout sonyericsson slimshad skateboard shonuf shoes sheep shag ring riccardo rfntymrf redcar qwe321 qqqwww proview prospect persona penetration peaches1 peace1 olympus oberon nokia6233 nightwish munich morales mone mohawk merlin1 Mercedes mega maxwell1 mash4077 marcelo mann mad macbeth LOVE loren longer lobo leeds lakewood kurt krokodil kolbasa kerstin jenifer hott hello12 hairball gthcbr grin grandam gotribe ghbrjk ggggggg FUCKYOU fuck69 footjob flasher females fellow explore evangelion egghead dudeman doubled doris dolemite dirty1 devin delmar delfin David daddyo cromwell cowboy1 closer cheeky ceasar cassandr camden cabernet burns bugs budweiser boxcar boulder biggun beloved belmont beezer beaker Batman bastards bahamut azertyui awnyce auggie aolsucks allegro 963963 852852 515000 45454545 31011990 29011987 28071986 28021986 27051987 27011988 26051988 26041991 26041986 25011993 24121986 24061992 24021991 24011990 23051986 23021988 23011990 21121986 21111990 21071989 20071986 20051985 20011989 1943 19111987 19091988 18041990 18021986 18011986 17101987 17091987 17021985 17011990 16061985 1598753 15051986 14881488 14121989 14081988 14071986 13111984 122112 12121989 12101985 12051985 111213 11071986 1103 11011987 10293847 101112 10081985 10061987 10041983 0911 07091982 07081986 06061987 06041987 06031983 04091986 03071986 03051987 03051986 03031990 03011987 02101978 02091973 02081974 02071977 02071971 0192837465 01051988 01051986 01011973 ????? zxcv123 zxasqw yyyy yessir wordup wizards werty watford Victoria vauxhall vancouve tuscl trailer touching tokiohotel suslik supernov steffen spider1 speakers spartan1 sofia signal sigmachi shen sheeba sexo sambo salami roger1 rocknroll rockin road reserve rated rainyday q123456789 purpl puppydog power123 poiuytre pointer pimping phialpha penthous pavement outside odyssey nthvbyfnjh norbert nnnnnnnn mutant Mustang mulligan mississippi mingus Merlin magic32 lonesome liliana lighting lara ksenia koolaid kolokol klondike kkkkkkk kiwi kazantip junio jewish jajaja jaime jaeger irving ironmaiden iriska homemade herewego helmut hatred harald gonzales goldfing gohome gerbil genesis1 fyfnjkbq freee forgetit foolish flamengo finally favorite6 exchange enternow emilio eeeeeee dougie dodgers1 deniro delaware deaths darkange commande comein cement catcher cashmone burn buffet breaker brandy1 bordeaux books bongo blue99 blaine birgit billabon benessere banan awesome1 asdffdsa archange annmarie ambrosia ambrose alleycat all4one alchemy aceace aaaaaaaaaa 777999 43214321 369258147 31121988 31121987 30061987 30011986 2fast4u 29041985 28121984 28061986 28041992 28031982 27111985 27021991 26111985 26101986 26091986 26031986 25021988 24111990 24101986 24071987 24011987 23051991 23051987 23031987 222777 22071983 22051986 21101989 21071987 21051986 20081986 20061986 20031986 20021985 20011988 19641964 19111986 19101986 19021990 18051987 18031991 18021987 16111982 16011987 15111984 15091988 15061988 15031988 15021983 14021989 14011988 14011987 12348765 12345qaz 1234566 12111990 12091988 12051989 12051987 12031988 12021985 12011985 11111986 11091984 1109 11071989 1016 10071985 10061984 10041990 10031989 10011988 06071983 05021988 03041987 02091982 02091971 02061974 02051990 02051979 02011990 01051990 010390 01021985 youtube yasmin woodstoc wonderful wildone widget whiplash ukraine tyson1 twinkie trouble1 treetop tigers1 their testing1 tarpon tantra summer69 stickman stafford spooge spliff speedway somerset smoothie siobhan shuttle shodan SHADOW selina segblue2 sebring scheisse Samantha rrrr roll riders revolution redbone reason rasmus randy1 rainbows pumper pornking point ploppy pimpdadd payday pasadena p0o9i8u7 opennow nittany newark navyseal nautica monic mikael metall Marlboro manfred macleod luna luca longhair lokiloki lkjhgfds lefty lakers1 kittys killa kenobi karine kamasutra juliana joseph1 jenjen jello interne houdini gsxr1000 grass gotham goodday gianni getting gannibal gamma flower2 fishon Fabie evgeniy drums dingo daylight dabomb cornwall cocksucker climax catnip carebear camber butkus bootsy blue42 auto austin31 auditt ariel alice1 algebra advance adrenalin 888999 789654123 777333 5Wr2i7H8 4567 3ip76k2 32167 31031987 30111987 30071986 30061983 30051989 30041991 28071987 28051990 28051985 27041985 26071987 26061986 26051986 25121985 25051985 24081988 24041988 24031987 24021988 23skidoo 23121986 23091987 23071985 23061992 22111985 22091986 22081991 22071990 22061985 21081985 21071992 21021987 20101988 20061984 20051989 20041990 1Dragon 19091990 19031987 18121984 18081988 18061991 18041991 18011988 17061991 17021987 16031988 16021987 15091989 15081990 15071983 15041987 14091990 14081990 14041992 14041987 14031989 13081985 13021987 123qwert 12345qwer 12345abc 123456t 123456789m 1212121212 12081983 12021991 111112 11101986 11081988 11061989 11041991 11011989 1018 1015 10121986 10121985 10101989 10041991 09091986 09081988 09051986 08071988 08011986 07101987 07071985 0660 06061985 06011988 05031991 05021987 04061984 04051985 02101973 02061981 02061972 02041973 02011979 01101987 01051985 01021987 workout wonderboy winter1 wetter werdna vvvv voyager1 vagabond trustme toonarmy timtim Tigger thrasher terra swoosh supra stigmata stayout status square sperma smackdown sixty9 sexybabe sergbest senna scuba1 scrapper samoht sammy123 salem rugger royalty rivera ringo restart reginald readers raleigh rainbow1 rage prosper pitch pictures petunia peterbil perfect1 patrici pantera1 pancake p4ssw0rd outback norris normandy nevermore needles nathan1 nataly narnia musical mooney michal maxdog MASTER madmad m123456 lumina luckyone luciano linkin lillie leigh kirkland kahlua junkmail Joshua josephin Jordan23 johnson1 jocelyn jeannie javelin inlove honor holein1 harbor grisha gina gatit futurama firenze fireblad fellatio esquire errors emmett elvisp drum driller dragonfl dragon69 dingle davinci crackers corwin compaq1 collie christa checker cartoons buttercup bungle budgie boomer1 body blue1234 biit bigguns barry1 audio atticus atlas Anthony angus1 Anai alisa alex12 aikman abacab 951357 7894 4711 321678 31101987 31051985 30121986 30091989 30031992 30031986 30011987 29061988 29061985 29031988 28061988 27061983 27031986 27021990 26101987 26071989 26071986 25081986 25061987 25051987 25041991 24101989 24071991 23111987 23091986 23051983 23031986 2222222222 22121989 22071991 22051991 22011985 21121985 21031985 20121988 20121986 20061990 20051987 1q2q3q 1944 19091983 19061992 1905 19021991 18121987 18121983 18111986 16121986 16091987 16071991 16071987 15111989 15031990 14041986 13121983 13101987 13091984 13071990 1245 12345m 1234568 123456789qwe 1234567899 1234561 1228 12211221 12121991 12121986 12101990 12101984 12091991 1209 12081988 12071990 12071988 115599 11111a 11041990 1028 10081990 10081983 10071990 10061989 10011992 09111987 09081985 08121987 08111984 08101986 08051989 07091988 07081987 07071988 07071984 07071982 07051987 06031992 05111986 05051991 05031990 05011987 04111988 04061987 04041987 040404 02081973 02061978 02031991 02031990 02011976 01071984 01041980 01021992 zaqwsxcde yyyyyyyy worthy woowoo wind William warhamme walton vodka venom velocity treble tralala tigercat tarakan sunlight streaming starr sonysony smart1 skylark sites shower sheldon seneca sedona scamper sand sabrina1 romantic rockwell rabbits q1234567 puzzle protect poker1 plato plastics pinnacle peppers pathetic patch pancakes ottawa ooooo offshore octopus nounours nokia1 neville ncc74656 natasha1 nastia mynameis motor motocros middle met2002 meow meliss medina meadow matty masterp manga lucia loose linden lhfrjy letsdoit leopold lawson larson laddie ladder kristian kittie jughead joecool jimmys iklo honeys hoffman hiking hello2 heels harrier hansol haley granada gofast fyutkjxtr frogs francisc four fields farm faith1 fabio dreamcas dragster doggy1 dirt dicky destiny1 deputy delpiero dbnfkbr dakota1 daisydog cyprus cutie cupoi colonial colin clovis cirrus chewy chessie chelle caster cannibal candyass camping cable bynthytn byebye buzzer burnout burner bumbum bumble briggs brest boyz bowtie bootsie bmwbmw blanche blanca bigbooty baylor base azertyuiop austria asd222 armando ariane amstel amethyst airman afrika adelina acidburn 7734 741741 66613666 44332211 31071990 31051993 30051987 30011990 29091987 29061986 29011982 2828 28101986 28081990 28081986 28011988 27111989 27031992 27021992 26081986 25081985 25031991 25031983 24121987 24091991 23111989 23091989 23091985 23061989 22091991 22071985 22071984 22061984 22051989 22051987 22031986 22011992 21061988 21031984 20071988 20061983 20041985 1qazzaq1 1qazxsw23edc 19991999 19061991 18101985 18051989 18031988 18021992 18011985 17051990 17051989 17051987 17021989 16091988 16081986 16061988 16061987 15121987 15091985 15081986 15061985 15011983 14101986 1357911 13071987 13061985 13021985 123456qqq 123456789d 1234509876 12131213 12111991 12111985 12081990 12081987 12071991 1207 120689 1120 11071987 11051988 1104 11031983 10091984 10071989 10071986 10061985 10051990 10041987 10031993 10031990 09091988 09051987 09041986 08081990 08081989 08021990 07101984 07071989 07041987 07031989 07021991 06061981 06021986 05121990 05061988 05031987 04071988 04071986 04041986 03101991 03091983 03051988 03041983 03031992 02081970 02061971 02051970 02041972 02031974 02021978 0202 02011977 01121990 01091992 01081992 01081985 01011972 007bond zapper vipergts vfntvfnbrf vfndtq tujhrf tripleh track THOMAS thierry thebear systems supernova stone1 stephen1 stang stan spot sparkles soul snowbird snicker slonik slayer1 sixsix singapor shauna scissors savior samm rumble rrrrr robin1 renato redstar raphael q1w2e3r pressure poptart playball pizzaman pinetree phyllis pathfind papamama panter pandas panda1 pajero pacino orchard olive nightmar nico Mustang1 mooses montrose montecar montag melrose masterbating maserati marshal makaka macmac mackie lockdown liverpool1 link lemans leinad lagnaf kingking killer123 kaboom jeter2 jeremy1 jeepster jabber itisme italy ilovegod idefix howell hores HIZIAD hewitt hellsing Heather gonzo1 golden1 GEORGE generic gatsby fujitsu frodo1 frederik forlife fitter feelgood fallon escalade enters emil eleonora earl dummy donner dominiqu dnsadm dickens deville delldell daughter contract contra conquest compact christi chill chavez chaos1 chains casio carrots building buffalo1 brennan boubou bonner blubber blacklab behappy barbar bambi babycake aprilia ANDREW allgood alive adriano 808080 7777777a 777666 31121986 31121985 31051991 31051987 30121988 30121985 30101988 30061988 29041988 27091991 26121989 26061989 26031991 25111991 25031984 25021986 24121989 24121988 24101990 24101984 24071992 24051989 24041986 23091991 23061987 23041988 23021992 23021983 22111988 22091990 22091984 22051988 21111986 21101988 21101987 21091989 21051990 21021989 20101987 20071984 20051983 20031990 20031985 20011983 1passwor 19111985 19081987 19051983 19041985 18121990 18121985 18121812 18091987 17121985 17111987 17071987 17071986 17061987 17041986 17041985 16121991 16101986 16041988 16041985 16031986 16021988 16011986 15121983 15101991 15061984 15011988 14091987 14061988 14051983 13101992 13101988 13101982 13071989 13071985 13061991 13051990 13031989 123456n 1234567890- 123450 1216 12101989 1208 12071984 12061987 12041991 12031990 12021984 1117 11091986 11091985 11081986 1026 10101988 10101980 10091986 10091985 10081987 10051988 10021987 10021986 09041985 09031987 08041985 08031987 07061988 07041989 07021980 06011982 05121988 05061989 05051986 04031991 03071985 03061986 03061985 03031987 03031984 03011991 02111987 02061990 02011971 01091988 01071990 01061983 01051980 01022010 000777 000123 young1 yamato winona winner1 whatthe weiner weekend volleyba volcano virginie videos vegitto uptown tycoon treefrog trauma town toast titts these therock1 tetsuo tennesse tanya1 success1 stupid1 stockton stock stellar springs spoiled someday skinhead sick shyshy shojou shampoo sexman sex69 saskia Sandra s123456 russel rudeboy rollin ridge ride rfgecnf qwqwqwqw pushkin puck probes pong playmate planes piercing phat pearls password9 painting nineball navajo napalm mohammad miller1 matchbox marie1 mariam mamas malish maison logger locks lister lfitymrf legos lander laetitia kenken kane johnny5 jjjjjjj jesper jerk jellybean jeeper jakarta instant ilikeit icecube hotass hogtied having harman hanuman hair hacking gumby gramma GOLF goldeneye gladys furball fuckme2 franks fick fduecn farmboy eunice erection entrance elisabet elements eclipse1 eatmenow duane dooley dome doktor dimitri dental delaney Dallas cyrano cubs crappy cloudy clips cliff clemente charlie2 cassandra cashmoney camil burning buckley booyah boobear bonanza bobmarley bleach bedford bathing baracuda antony ananas alinka alcatraz aisan 5000 49ers 334455 31051982 30051988 30051986 29111988 29051992 29041989 29031990 28121989 28071985 28021983 27111990 27071988 26071984 26061991 26021992 26011990 26011986 25091991 25091989 25081989 25071987 25071985 25071983 25051988 25051980 25041987 25021985 24101991 24101988 24071990 24061985 24041985 24041984 23456 23111986 23101987 23041991 23031983 22071992 22071988 21121989 21111989 21111983 21101983 21041991 21041987 21031986 21021990 21021988 20081990 20061991 20061987 20032003 20031992 1qw23er4 1q1q1q1q 1Master 19121988 19081986 19071989 19041986 18111983 18071990 18071989 18071986 18031986 17121987 17091985 17071990 17051983 16091990 15081989 15071990 15051992 15051989 15031991 15011990 14031986 13091988 13091987 13091986 13081986 13071982 13051986 13041989 13021991 1269 123890 1234rewq 12345r 1231234 12111984 12091986 12081993 12071992 1206 12021990 111555 11111991 11091990 11061987 11061986 11061984 11041985 11031986 1030 1029 1014 101091m 10041984 10031980 10011980 09051984 08071985 07081984 07041988 06101989 06061988 06041984 05091987 05081992 05081986 05071985 05041985 04111991 04071987 04021990 03091988 03061988 03041989 03041984 03031991 02091978 01071988 01061992 01041993 01041983 01031981 0069 zyjxrf xian wizard1 winger wilder welkom wearing weare138 vanessa1 usmarine unlock thumb this tasha1 talks talbot summers sucked storage sqdwfe socce sniffing smirnov shovel shopper shady semper screwy schatz samanth salman rugby1 rjhjkm rita rfhfylfi retire ratboy rachelle qwerasdfzxcv purple1 prince1 pookey picks perkins patches1 password99 oyster olenka nympho nikolas neon muslim muhammad morrowind monk missie mierda mercede melina maximo matrix1 Martin mariner mantle mammoth mallrats madcow macintos macaroni lunchbox lucas1 london1 lilbit leoleo KILLER kerry kcchiefs juniper jonas jazzy istheman implants hyundai hfytnrb herring grunt grimace granite grace1 gotenks glasses giggle ghjcnbnenrf garnet gabriele gabby fosters forever1 fluff Fktrcfylh finder experienced dunlop duffer driven dragonballz draco downer douche doom discus darina daman daisey clement chouchou cheerleaers Charles charisma celebrity cardinals captain1 caca c2h5oh bubbles1 brook brady jeremyevans-rodauth-b53f402/doc/000077500000000000000000000000001515725514200166115ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/doc/CHANGELOG.old000066400000000000000000000521761515725514200206130ustar00rootroot00000000000000=== 1.23.0 (2020-03-06) * Remove specs from the gem to reduce gem size by over 20% (jeremyevans) * Make rodauth.authenticated? return true on OTP setup page (jeremyevans) (#68) * Display link to email auth request form when user has entered login and incorrect password if using email_auth feature (janko) (#65) * Add *_path and *_url methods for all *_route methods (janko) (#64) * Add send_email configuration method for configuring how email is sent (janko) (#63) === 1.22.0 (2019-10-29) * Add jwt_cors feature to handle Cross-Origin Resource Sharing when using the jwt feature (jeremyevans) * Add space before newline after links in email, fixing issues with some webmail providers with broken autolinkers (jeremyevans) === 1.21.0 (2019-07-24) * Support rotp 5.1 in the otp feature (jeremyevans) * Log user out when locking out OTP account if no fallback options available (jeremyevans) === 1.20.0 (2019-06-07) * Support rotp 5 in the otp feature (jeremyevans) * Add jwt_refresh feature to allow shorter lived JWTs with a refresh token for creating new JWTs (allavena, jeremyevans) (#28) * Fix disallow_password_reuse feature when account_password_hash_column is not set and verify_account feature is not used (cptaffe) (#59) * Rename no_matching_email_auth_key_message to no_matching_email_auth_key_error_flash for consistency (jeremyevans) * Rename no_matching_verify_login_change_key_message to no_matching_verify_login_change_key_error_flash for consistency (jeremyevans) * Rename attempt_to_login_to_unverified_account_notice_message to attempt_to_login_to_unverified_account_error_flash for consistency (jeremyevans) * Rename attempt_to_create_unverified_account_notice_message to attempt_to_create_unverified_account_error_flash for consistency (jeremyevans) * Rename no_matching_verify_account_key_message to no_matching_verify_account_key_error_flash for consistency (jeremyevans) * Rename no_matching_unlock_account_key_message to no_matching_unlock_account_key_error_flash for consistency (jeremyevans) * Rename no_matching_reset_password_key_message to no_matching_reset_password_key_error_flash for consistency (jeremyevans) * Add otp_keys_use_hmac? and otp_setup_raw_param configuration methods to the otp feature for configuring use of HMACs with OTP authentication (jeremyevans) * Do not set a previous account password before password has been set when using disallow_password_reuse with verify_account_set_password? (jeremyevans) * Add allow_raw_single_session_key? to single_session feature to allow raw single single session tokens, for graceful transition (jeremyevans) * Add raw_remember_token_deadline to remember feature to allow raw remember tokens before given deadline, for graceful transition (jeremyevans) * Add allow_raw_email_token? configuration method to email_base feature to allow raw tokens when email_token_hmac_secret is set, for graceful transition (jeremyevans) * Add hmac_secret configuration method, used for additional security using HMACs (jeremyevans) * Use urlsafe base64 for new token keys on Ruby 1.8 (jeremyevans) * Add login_input_type configuration method for setting the input type for login inputs (jeremyevans) * Add formatted_field_error configuration method for formatting error messages (jeremyevans) * Add field_error_attributes configuration method for configuring attributes for fields with errors (jeremyevans) * Add field_attributes configuration method for configuring attributes for specific fields (jeremyevans) * Add default_field_attributes configuration method to set default attributes for all input fields (jeremyevans) * Make error handling accessible by default using aria-invalid and aria-describedby attributes (jeremyevans) * Add mark_input_fields_as_required? configuration method for whether inputs should use the required attribute (jeremyevans) * Add input_field_error_message_class configuration method for the CSS class used for error messages (jeremyevans) * Wrap all error messages in a span so they can be styled (jeremyevans) * Add input_field_error_class configuration method for customizing CSS class to use for inputs with errors (jeremyevans) * Add input_field_label_suffix configuration method for suffixing all input labels, useful for labeling fields as required (jeremyevans) * Add verify_account_resend_explanatory_text configuration method to verify_account feature for configuring text (jeremyevans) * Add unlock_account_explanatory_text and unlock_account_request_explanatory_text configuration methods to lockout feature for configuring text (jeremyevans) * Add reset_password_explanatory_text configuration method to reset_password feature for configuring text (jeremyevans) * Add otp_provisioning_uri_label and otp_secret_label configuration methods to otp feature for configuring labels displayed during OTP setup (jeremyevans) * Add add_recovery_codes_heading configuration method to recovery_codes feature for configuring heading text (jeremyevans) * Use define_method instead of instance_exec for route dispatching for better performance (jeremyevans) * Add already_an_account_with_this_login_message configuration method (1gor) (#54) === 1.19.1 (2018-11-16) * Support rotp 4 in the otp feature (jeremyevans) === 1.19.0 (2018-11-16) * Avoid unneeded database queries in the two factor authentication support (jeremyevans) * Add {before,after}_verify_login_change_email configuration methods, called around sending the verify login change email (jeremyevans) * Add after_account_lockout configuration method, called after locking out an account (jeremyevans) * Add default_post_email_redirect configuration method, setting default for all redirects after emailing when not logged in (jeremyevans) * Gracefully handle failure when new login is already taken in the verify_login_change feature (jeremyevans) * Support optional email rate limiting in the lockout, reset password, and verify account features (jeremyevans) * Make MySQL rodauth_get_salt function handle accounts without password hashes (jeremyevans) * Add email_auth feature, for authentication using links sent via email (jeremyevans) * Deprecate before_otp_authentication_route, users should switch to before_otp_auth_route (jeremyevans) * Add use_multi_phase_login? configuration method to login feature, separating login entry from password entry (jeremyevans) * Don't disable use of date_arithmetic extension on !MySQL when using lockout, remember, or reset password features (jeremyevans) === 1.18.0 (2018-07-18) * Add confirm_password_redirect_session_key configuration method to confirm_password feature (jeremyevans) * Work with Roda sessions plugin, using string keys for session information if that is used (jeremyevans) * Add flash_error_key and flash_notice_key configuration for setting keys used in flash (jeremyevans) === 1.17.0 (2018-06-11) * Support Roda route_csrf plugin for request-specific CSRF tokens (jeremyevans) === 1.16.0 (2018-03-09) * Add disallow_common_passwords feature, for disallowing the usage of the most common passwords (jeremyevans) * Remove calling request [] method to get request param values, as it is deprecated in the current version of rack (jeremyevans) === 1.15.0 (2018-01-29) * Add create_account_set_password? and verify_account_set_password? methods to delay setting password until account verification (jeremyevans) === 1.14.0 (2017-12-19) * Don't allow unlocking expired accounts when using account_expiration and lockout features (jeremyevans) * Don't allow resetting passwords for expired accounts when using account_expiration and reset_password features (jeremyevans) * Add change_password_notify feature for emailing when user uses change password feature (jeremyevans) === 1.13.0 (2017-11-21) * Add json_response_body(hash) configuration method to jwt feature (jeremyevans) * Support invalid_previous_password_message configuration method in change_password feature (jeremyevans) * Use custom error statuses if only_json? and json_response_custom_error_status? are true even if request isn't in json format (jeremyevans) * Add cache_templates configuration method for disabling caching of templates (adam12, jeremyevans) (#46) === 1.12.0 (2017-10-03) * [SECURITY] Clear expired password reset key for account before retrieving password reset key (chanks, jeremyevans) (#43) * Update migrations to work with Sequel 5 (jeremyevans) * Add require_http_basic_auth configuration method to http_basic_auth feature (jeremyevans) (#41) * Support passing :search_path option to Rodauth.create_database_authentication_functions when using PostgreSQL (jeremyevans) * Support passing options to Rodauth.{create,drop}_database_previous_password_check_functions (jeremyevans) * Support passing options to Rodauth.drop_database_authentication_functions (jeremyevans) === 1.11.0 (2017-04-24) * Add login_required_error_status, and use it in the jwt feature when custom error statuses are allowed (jeremyevans) * Deal better with time differences between the database and application servers in the password_expiration plugin (jeremyevans) * Add rodauth.valid_jwt? method for checking if a valid JWT was submitted with the request (jeremyevans) === 1.10.0 (2017-03-23) * Add Internals Guide (jeremyevans) * Set FeatureConfiguration instances to constants, just like Feature instances (jeremyevans) * When reopening rodauth configuration in roda subclass, automatically subclass rodauth configuration so it doesn't modify superclass (jeremyevans) * Add verify_login_change feature as an alternative to verify_change_login, where the change doesn't take affect until after verification (jeremyevans) (#31) * Add login_failed_reset_password_request_form for customizing the HTML used for the request password request form on login failures (jeremyevans) * Make reset password request form available without requiring a login attempt, and provide a login field in that case (jeremyevans) (#30) * Make resending verify account email request form available without requiring a login/account creation attempt, and provide a login field in that case (jeremyevans) (#30) * Fix resending verify account email when attempting to create a new account with same login as unverified account when using verify_account_grace_period feature (jeremyevans) (#30) * Fix precompile_rodauth_templates usage with reset_password feature (jeremyevans) === 1.9.0 (2017-02-22) * Make reset-password use existing password reset key if one is present (jeremyevans) (#26) * Add Roda.precompile_rodauth_templates method, useful to save memory when forking, or when chrooting (jeremyevans) === 1.8.0 (2017-01-06) * Add json_response_custom_error_status? option to jwt feature to use specific 4xx statuses instead of 400 (jeremyevans) * Use 4xx error statuses for errors, instead of using a 200 success status (jeremyevans) === 1.7.0 (2016-11-22) * Make reset password, unlock account, and verify account pages not leak keys to external servers via Referer header (jeremyevans) === 1.6.0 (2016-10-24) * Add http_basic_auth feature (TiagoCardoso1983, jeremyevans) (#12) * Move login hooks from login feature to base, to be usable by other features (jeremyevans) * Make reset_password feature not attempt to render a template in json-only mode (jeremyevans) (#11) * Memoize jwt_payload in jwt feature, as it may be called more than once (mwpastore) (#10) * Add jwt_decode_opts configuration method to jwt feature, for specifying options to JWT.decode, allowing for JWT claim verification (mwpastore, jeremyevans) (#9) * Add jwt_session_hash configuration method to jwt feature, for modifying the session information stored in the JWT hash, allowing for setting JWT claims (mwpastore, jeremyevans) (#9) * Add jwt_session_key configuration method to jwt feature, for nesting the session under a key in the JWT, avoiding reserve claim names (mwpastore, jeremyevans) (#9) * Add jwt_symbolize_deeply? configuration method to jwt feature, for symbolizing nested keys in session hash when using JWT (mwpastore) (#9) === 1.5.0 (2016-09-22) * Return error instead of raising exception in the jwt feature if an invalid jwt format is submitted in the Authorization header (jeremyevans) * Add jwt_authorization_remove configuration method to jwt feature, for regexp to remove from Authorization header before JWT processing (jeremyevans) * Add jwt_authorization_ignore configuration method to jwt feature, for regexp to skip processing of JWTs in Authorization header (jeremyevans) * Add json_accept_regexp configuration method to jwt feature, for the regexp used to match against the Accept header (jeremyevans) * Add use_jwt? configuration method to jwt feature, for whether to use the JWT token or rack session for authentication information (jeremyevans) * Add jwt_check_accept? configuration method to jwt feature, to return 406 error if Accept header is present and json is not accepted (jeremyevans) * Add json_response_content_type configuration method to jwt feature, for the content type to set for json responses, default to application/json (jeremyevans) * Add json_request_content_type_regexp configuration method to the jwt feature, for the regexp that recognize a request as a json request (jeremyevans) * Add session_jwt method to the jwt feature, which returns a string for the encoded JWT for the current session (jeremyevans) * If the only_json? setting is true, return a 400 error if the request content type to a rodauth endpoint is not json (jeremyevans) * The only_json? setting in the jwt feature is now only true by default if :json=>:only plugin option was used (jeremyevans) * Don't have jwt feature break if HTTP Basic/Digest authentication is used (jeremyevans) * Add template_opts configuration method, for overriding view/method options (jeremyevans) === 1.4.0 (2016-08-18) * Add update_password_hash feature, for updating the password hash when the hash cost changes (jeremyevans) === 1.3.0 (2016-07-19) * Add login_maximum_length, defaulting to 255 (jeremyevans) === 1.2.0 (2016-06-15) * Add otp_drift configuration method to otp plugin, setting number of seconds of allowed drift (jeremyevans) * Don't allow setting passwords containing the ASCII NUL character, as bcrypt truncates at that point (jeremyevans) (#4) === 1.1.0 (2016-05-13) * Support :csrf=>false and :flash=>false plugin options (jeremyevans) === 1.0.0 (2016-04-15) * Remove invalid remember cookies to prevent unnecessary future database checks (jeremyevans) * Extend remember deadline in cookie in addition to database (jeremyevans) * Make tokens work with string account ids (jeremyevans) * Add verify_change_login feature for requiring account reverification on login changes (jeremyevans) * Set correct cookie expiration in the remember feature (jeremyevans) * Split confirm_password feature from remember feature (jeremyevans) * Add verify_account_grace_period feature, for allowing logins into unverified accounts for a certain period after creation (jeremyevans) * Move login/password requirements settings to login password requirements base feature (jeremyevans) * Add session_expiration feature, expiring sessions based on inactivity and max lifetime checks (jeremyevans) * Add password_grace_period feature, for not requiring password entry if password was recently entered (jeremyevans) * Make create/verify account autologin true by default (jeremyevans) * Optimize routing using a hash table, disallow per-request routes (jeremyevans) * Add ability to turn off login/password confirmations (jeremyevans) * Don't allow changing login to the same as the current login (jeremyevans) * Only allow requesting account unlocks if the account is current locked out (jeremyevans) * Use separate routes for unlock account/reset password/verify account requests (jeremyevans) * Use separate routes for confirming passwords and changing remember settings (jeremyevans) * Add JWT feature for JSON API support using JWT tokens (jeremyevans) * Add account_select configuration option for setting which columns to select from accounts_table (jeremyevans) * Execute get_block and post_block in the Rodauth::Auth instance scope (jeremyevans) * Store field errors in the rodauth object instead of instance variables in the Roda scope (jeremyevans) * Add rodauth.redirect to abstract redirection code (jeremyevans) * Only use flash notices for successful requests, other requests that redirect now use an error flash (jeremyevans) * The before_* configuration methods now run directly before making the related database changes (jeremyevans) * Before hooks run before routes now use before_*_route instead of before_* configuration methods (jeremyevans) * Add token_separator configuration method to replace the default of _ (jeremyevans) * Rename account_id_value to account_id (jeremyevans) * Rename account_id to account_id_column and account_session_id to account_session_column (jeremyevans) * Make skip_status_checks? default to true unless loading verify_account or close_account features (jeremyevans) * Replace account_model with accounts_table and db, removing use of Sequel models (jeremyevans) * Extract shared email-related code into email_base feature (jeremyevans) * Add auth_class_eval to configuration block for adding custom methods (jeremyevans) * Add configuration_module_eval to feature definitions for adding custom configuration methods (jeremyevans) * Allow close_account feature to optionally delete accounts (jeremyevans) * Make close_account feature work when skipping status checks or when using account_password_hash_column (jeremyevans) * Add sms_codes feature, for codes received via SMS that can be used if TOTP authentication is not available (jeremyevans) * Attempt to handle unique constraint violations raised in race conditions where possible (jeremyevans) * Add _before and _after internal methods, make ununderscored methods only for users (jeremyevans) * Add single_session feature, for only allowing a single active session per account (jeremyevans) * Add account_expiration feature, for disallowing access to accounts after an amount of time since last login/activity (jeremyevans) * Check account status in rodauth.load_memory in remember plugin (jeremyevans) * Use csrf plugin automatically, depend on Roda >=2.6.0 (jeremyevans) * Make bcrypt and mail development dependencies instead of runtime dependencies in the gem (jeremyevans) * Add password_expiration feature, requiring users to change their password after a given amount of time (jeremyevans) * Add disallow_password_reuse feature, checking that a new password doesn't match previous passwords (jeremyevans) * Add password_complexity feature, allowing more sophisticated password complexity checks (jeremyevans) * Add rodauth.remember_param and .remember_confirm_param for overriding parameter names (jeremyevans) * Check that new password is not the same as existing password in change password and reset password features (jeremyevans) * Add rodauth.login_meets_requirements? for checking if a login is valid, by default a valid email address (jeremyevans) * Allow unlock account to optionally require the user's current password (jeremyevans) * Add support for running on Microsoft SQL Server with database functions for authentication (jeremyevans) * Make change password, change login, and close account require the user's current password by default (jeremyevans) * Add rodauth.csrf_tag to make it easy to replace the CSRF tag implementation (jeremyevans) * Switch unlock_account_autologin? to be true by default (jeremyevans) * Add rodauth.authenticated? and .require_authentication (jeremyevans) * Add recovery_codes feature, for single use codes that can be used if TOTP authentication is not available (jeremyevans) * Add otp feature, for 2 factor authentication via TOTP (jeremyevans) * Add support for running on MySQL with database functions for authentication (jeremyevans) * Add *_interval and set_deadline_values? methods for setting deadline intervals on a per-request basis (jeremyevans) * Add remember_deadline_column method for overriding the column used for storing the deadline (jeremyevans) * Add rodauth/migrations file for DRYing up the database function creation (jeremyevans) * Add Rodauth.version for getting the version (jeremyevans) * External features should now be requirable via rodauth/features/feature_name instead of roda/plugins/rodauth/feature_name (jeremyevans) * Make Rodauth top level module instead of under Roda::RodaPlugins (jeremyevans) * Require mail at configure time instead of run time if using a feature that sends email, use require_mail? false to disable (jeremyevans) * Require bcrypt at configure time instead of run time, use require_bcrypt? false to disable (jeremyevans) * Always require securerandom (jeremyevans) * Make remember, password reset, and lockout features work on non-PostgreSQL databases (jeremyevans) * Support authentication without database functions when password hashes are stored in separate table (jeremyevans) * Remove overriding of route/get/post blocks (jeremyevans) * Make lockout feature work on databases not supporting UPDATE RETURNING (jeremyevans) * Add timing safe comparison of tokens (jeremyevans) === 0.10.0 (2016-02-17) * Retrieve salt from database and compute hash client side, instead of computing hash on server (jeremyevans) === 0.9.1 (2015-08-13) * Don't use csrf plugin automatically (jeremyevans) === 0.9.0 (2015-08-12) * Initial public release jeremyevans-rodauth-b53f402/doc/account_expiration.rdoc000066400000000000000000000050661515725514200233670ustar00rootroot00000000000000= Documentation for Account Expiration Feature The account expiration feature disallows access to accounts after a configurable amount of time since the last login or activity (default: 180 days since last login). By default, this feature does not track activity times as that can slow things down, but if you want to record activity times, you can do so by adding the following code to your routing block: rodauth.update_last_activity Note that it only makes sense to do this if you are also expiring accounts based on last activity instead of last login, via the +expire_account_on_last_activity?+ configuration setting. Note that this feature does not support the reenabling of expired accounts, that is something you would have to implement yourself, if you need such a feature. == Auth Value Methods account_activity_expired_column :: The column in the +account_activity_table+ storing the expiration timestamp. account_activity_id_column :: The column in the +account_activity_table+ storing the account id. account_activity_last_activity_column :: The column in the +account_activity_table+ storing the last activity timestamp. account_activity_last_login_column :: The column in the +account_activity_table+ storing the last login timestamp. account_activity_table :: The database table use for storing account login/activity/expiration timestamps. account_expiration_error_flash :: The flash error to show when attempting to login to an account that has expired. account_expiration_redirect :: Where to redirect after attempting to login to an account that has expired. expire_account_after :: How long in seconds from last login or activity until an account is considered expired. expire_account_on_last_activity? :: Whether to use the last activity timestamp when checking an account for expiration. By default, this is false and it uses the last login timestamp. == Auth Methods account_expired? :: Whether the current account has expired. account_expired_at :: The expiration timestamp for the current account, nil if the account hasn't been expired. after_account_expiration :: Run arbitrary code after account expiration. last_account_activity_at :: The last activity timestamp for the current account, nil if the account hasn't had activity recorded yet. last_account_login_at :: The last login timestamp for the current account, nil if the account hasn't had a login recorded yet. set_expired :: Set the current account as having expired. update_last_activity :: Update the last activity timestamp for the account. update_last_login :: Update the last login timestamp for the account. jeremyevans-rodauth-b53f402/doc/active_sessions.rdoc000066400000000000000000000105031515725514200226620ustar00rootroot00000000000000= Documentation for Active Sessions Feature The active sessions feature stores an id for each session in a database table whenever a user logs in to the system. In your routing block, you can check that the session id given is still listed as an active session: rodauth.check_active_session On logout, the session id is removed from the database table, so attempts to reuse the session id after that will fail. Additionally, this supports an option on logout to globally logout all sessions, which removes all active session ids for the account from the database table. In addition to removing sessions on logout, this also by default supports session inactivity deadlines (based on time since last use) and session lifetime deadlines (based on time since session creation). To prevent the sessions table from growing indefinitely, sessions that are passed either deadline are removed when checking if the current session is active. This depends on the logout feature. == Auth Value Methods active_sessions_account_id_column :: The column in the +active_sessions_table+ containing the account id. active_sessions_created_at_column :: The column in the +active_sessions_table+ containing the time of session creation. active_sessions_error_flash :: The flash error to display if the current session is no longer active. active_sessions_last_use_column :: The column in the +active_sessions_table+ containing the time the session was last used. active_sessions_redirect :: Where to redirect if the current session is no longer active. active_sessions_session_id_column :: The column in the +active_sessions_table+ containing the session_id. active_sessions_table :: The database table storing active session keys. global_logout_label :: The label for the global logout checkbox on the logout page. global_logout_param :: The parameter name for the global logout checkbox on the logout page. inactive_session_error_status :: The error status to use when a JSON request is made and the session is no longer active, 401 by default. session_id_session_key :: The session key name to use for storing the session id. session_inactivity_deadline :: The number of seconds since last use after which the session will be considered expired (1 day by default). Can be set to nil to not check session inactivity. session_lifetime_deadline :: The number of seconds since session creation after which the session will be considered expired (30 days by default). Can be set to nil to not check session lifetimes. update_current_session? :: Whether the update current session with +active_sessions_update_hash+. By default returns true if +session_inactivity_deadline+ is set. == Auth Methods active_sessions_insert_hash :: The hash to insert into the +active_sessions_table+. active_sessions_key :: The active session key for the current account. active_sessions_update_hash :: The hash to update the currently active session when +update_current_session?+ is true. By default updates last use to current time. add_active_session :: Create a session id for the session and populate the session and add the session id to the database. currently_active_session? :: Whether the session is currently active, by checking the database table. handle_duplicate_active_session_id(exception) :: How to handle the case where a duplicate session id for the account is inserted into the table. Does nothing by default. This should only be called if the random number generator is broken. no_longer_active_session :: What action to take if +rodauth.check_active_session+ is called and the session is no longer active. remove_active_session(session_id) :: Removes the active session matching the given session ID from the database. Useful for implementing session revoking. remove_all_active_sessions :: Remove all active sessions for the account from the database, used for global logouts and when closing accounts. remove_all_active_sessions_except_for(session_id) :: Remove all active sessions for the account from the database, except for the session id given. remove_all_active_sessions_except_current :: Remove all active sessions for the account from the database, except for the current session. remove_current_session :: Remove current session from the database, used for regular logouts. remove_inactive_sessions :: Remove inactive sessions from the database, run before checking for whether the current session is active. jeremyevans-rodauth-b53f402/doc/argon2.rdoc000066400000000000000000000057611515725514200206630ustar00rootroot00000000000000= Documentation for Argon2 Feature The argon2 feature adds the ability to replace the bcrypt password hash algorithm with argon2 (specifically, argon2id). Argon2 is an alternative to bcrypt that offers the ability to be memory-hard. However, argon2 is weaker than bcrypt for interactive login environments (e.g. password check times under a second), so for the vast majority of web applications, using the argon2 feature will weaken the application's security. You should not use the argon2 feature unless the usage of argon2 is required or you are a cryptographer and understand why argon2 would be better than bcrypt for your application. If you are using this feature with Rodauth's database authentication functions, you need to make sure that the database authentication functions are configured to support argon2 in addition to bcrypt. You can do this by passing the +:argon2+ option when calling the method to define the database functions. In this example, +DB+ should be your Sequel::Database object: require 'rodauth/migrations' # If the functions are already defined and you are not using PostgreSQL, # you need to drop the existing functions. Rodauth.drop_database_authentication_functions(DB) # If you are using the disallow_password_reuse feature, also drop the # database functions related to that if not using PostgreSQL: Rodauth.drop_database_previous_password_check_functions(DB) # Define new functions that support argon2: Rodauth.create_database_authentication_functions(DB, argon2: true) # If you are using the disallow_password_reuse feature, also define # new functions that support argon2 for that: Rodauth.create_database_previous_password_check_functions(DB, argon2: true) The argon2 feature provides the ability to allow for a gradual migration from transitioning from bcrypt to argon2 and vice-versa, if you are using the update_password_hash feature. Argon2 is more configurable than bcrypt in terms of password hash cost specification. Instead of specifying the password_hash_cost value as an integer, you must specify the password hash cost as a hash, such as ({t_cost: 2, m_cost: 16}). If you are using the argon2 feature and if you have no bcrypt passwords in your database, you should use require_bcrypt? false in your Rodauth configuration to prevent loading the bcrypt library, which will save memory. == Auth Value Methods argon2_old_secret :: The previous secret key used as input at hashing time, used for argon2_secret rotation. In order to rotate the argon2_secret, you must also use the update_password_hash feature, and rotation will not be finished until all users have logged in using the new secret. argon2_secret :: A secret key used as input at hashing time, folded into the value of the hash. use_argon2? :: Whether to use the argon2 password hash algorithm for new passwords (true by default). The only reason to set this to false is if you have existing passwords using argon2 that you want to support, but want to use bcrypt for new passwords. jeremyevans-rodauth-b53f402/doc/audit_logging.rdoc000066400000000000000000000046761515725514200223130ustar00rootroot00000000000000= Documentation for Audit Logging Feature The audit logging feature adds audit logging of rodauth actions to a database table. It ties into the after hook processing used by all features so that all features that use after hooks automatically support audit logging. In addition to the configuration methods defined below, the audit logging feature also offers two additional configuration methods for action specific audit log messages and metadata, +audit_log_message_for+ and +audit_log_metadata_for+. These methods take the action symbol and either take a value or a block that returns a value to use for the message and metadata for that action: audit_log_message_for :login, "I have logged in" audit_log_metadata_for :logout, 'Uses'=>'JSON Metadata' audit_log_message_for :login_failure do "Login failure on domain #{request.host}" end audit_log_metadata_for :login_failure do {'ip'=>request.ip} end To skip audit logging for a particular action, you can set the log message for the action to nil. == Auth Value Methods audit_logging_account_id_column :: The id column in the +audit_logging_table+, should be a foreign key referencing the accounts table. audit_logging_message_column :: The message column in the +audit_logging_table+, containing the log message. audit_logging_metadata_column :: The metadata column in the +audit_logging_table+, storing metadata for the log (if any). audit_logging_table :: The name of the audit logging table. audit_log_metadata_default :: The default metadata to use for logs that do not have custom metadata specified by +audit_log_metadata_for+. == Auth Methods add_audit_log(account_id, action) :: Add an appropriate audit log entry for the account id and action. audit_log_insert_hash(account_id, action) :: A hash to use when inserting into the +audit_logging_table+. audit_log_message(action) :: The log message to use when logging the action, by default using +audit_log_message_for+ and +audit_log_message_default+. audit_log_message_default(action) :: The log message to use when logging the action for logs that do not have custom metadata specified by +audit_log_message_for+ audit_log_metadata(action) :: The metadata to use when logging the action, by default using +audit_log_metadata_for+ and +audit_log_metadata_default+. serialize_audit_log_metadata(metadata) :: Serialize the metadata for insertion into the database. By default, this converts the metadata using +to_json+, unless the metadata is nil. jeremyevans-rodauth-b53f402/doc/base.rdoc000066400000000000000000000336151515725514200204040ustar00rootroot00000000000000= Documentation for Base Feature The base feature is automatically loaded when you use Rodauth. It contains shared functionality that is used by multiple features. == Auth Value Methods === Most Commonly Used account_password_hash_column :: Set if the password hash column is in the same table as the login. If this is set, Rodauth will check the password hash in ruby. This is often used if you are replacing a legacy authentication system with Rodauth. accounts_table :: The database table containing the accounts. base_url :: The base URL to use, used when construct absolute links. It is recommended to set this if the application can be reached using arbitrary Host headers, as otherwise it is possible for an attacker to control the value. db :: The Sequel::Database object used for database access. domain :: The domain to use, required by some other features. It is recommended to set this if the application can be reached using arbitrary Host headers, as otherwise it is possible for an attacker to control the value. hmac_secret :: This sets the secret to use for all of Rodauth's HMACs. This is not set by default, in which case Rodauth does not use HMACs for additional security. However, it is highly recommended that you set this, and some features require it. mark_input_fields_as_required? :: Whether input fields should be marked as required, so browsers will not allow submission without filling out the field (default: true). prefix :: The routing prefix used for Rodauth routes. If you are calling in a routing subtree, this should be set to the root path of the subtree. This should include a leading slash if set, but not a trailing slash. require_bcrypt? :: Set to false to not require bcrypt, useful if using custom authentication or when using the argon2 feature without existing bcrypt password hashes. session_key :: The key in the session hash storing the primary key of the logged in account. session_key_prefix :: The string that will be prepended to the default value for all session keys. skip_status_checks? :: Whether status checks should be skipped for accounts. Defaults to true unless enabling the verify_account or close_account features. title_instance_variable :: The instance variable to set in the Roda scope with the page title. The layout should use this instance variable if available to set the title of the page. You can use +set_title+ if setting the page title is not done through an instance variable. === Other account_id_column :: The primary key column of the +accounts_table+. account_open_status_value :: The integer representing open accounts. account_select :: An array of columns to select from +accounts_table+. By default, selects all columns in the table. account_status_column :: The status id column in the +accounts_table+. account_unverified_status_value :: The integer representing unverified accounts. authenticated_by_session_key :: The key in the session hash storing an array of methods used to authenticate. autocomplete_for_field?(param) :: Whether to use an autocomplete attribute for the given parameter, defaults to +mark_input_fields_with_autocomplete?+. autologin_type_session_key :: The key in the session hash storing the type of autologin method used, if autologin was used to authenticate. cache_templates :: Whether to cache templates. True by default. It may be worth switching this to false in development if you are using your own templates instead of the templates provided by Rodauth. check_csrf? :: Whether Rodauth should use Roda's +check_csrf!+ method for checking CSRF tokens before dispatching to Rodauth routes, true by default. check_csrf_opts :: Options to pass to Roda's +check_csrf!+ if Rodauth calls it before dispatching. check_csrf_block :: Proc for block to pass to Roda's +check_csrf!+ if Rodauth calls it before dispatching. clear_tokens(reason) :: Called when there is an account change, clears tokens for the account. By examining the reason symbol you can get different behavior per action, but the default behavior is clear all tokens whenever there is an account. Clearing actions/reasons are +:reset_password+, +:verify_account+, +:change_login+, +:unlock_account+, and +close_account+. Tokens are cleared for the following features +reset_password+, +verify_account+, +verify_login_change+, +jwt_refresh+, +remember+, +email_auth+, +single_session+, and +active_sessions+. convert_token_id_to_integer? :: Whether token ids should be converted to a valid 64-bit integer value. If not set, defaults to true if +account_id_column+ uses an integer type, and false otherwise. default_field_attributes :: The default attributes to use for input field tags, if field_attributes returns nil for the field. default_redirect :: Where to redirect after most successful actions. field_attributes(field) :: The attributes to use for the input field tags for the given field (parameter name). field_error_attributes(field) :: The attributes to use for the input field tags for the given field (parameter name), if the input has an error. flash_error_key :: The flash key to use for error messages (default: +:error+ or 'error' depending on session support for symbols). flash_notice_key :: The flash key to use for notice messages (default: +:notice+ or 'notice' depending on session support for symbols). formatted_field_error(field, error) :: HTML to use for error messages for the field (parameter name), if the field has an error. By default, uses a span tag for the error message. hmac_old_secret :: This sets the previous secret used for Rodauth's HMACs, to allow for secret rotation. hook_action(hook_type, action) :: Arbitrary action to take on all hook processing, with hook type being +:before+ or +:after+, and action being symbol for related action. input_field_error_class :: The CSS class to use for input fields with errors. Can be a space separated string for multiple CSS classes. input_field_error_message_class :: The CSS class to use for error messages. Can be a space separated string for multiple CSS classes. input_field_label_suffix :: The suffix to use for all labels. Useful for noting that the fields are required. inputmode_for_field?(param) :: Whether to use an inputmode attribute for the given parameter, defaults to mark_input_fields_with_inputmode?. invalid_field_error_status :: The response status to use for invalid field value errors, 422 by default. invalid_key_error_status :: The response status to use for invalid key codes, 401 by default. invalid_password_error_status :: The response status to use for invalid passwords, 401 by default. invalid_password_message :: The error message to display when a given password doesn't match the stored password hash. lockout_error_status :: The response status to use a login is attempted to an account that is locked out, 403 by default. login_column :: The login column in the +accounts_table+. login_input_type :: The input type to use for logins. Defaults to email if login column is email and text otherwise. login_label :: The label to use for logins. login_param :: The parameter name to use for logins. login_required_error_status :: The response status to return when a login is required and you are not logged in, if not redirecting, 401 by default login_uses_email? :: Whether the login field uses email, used to set the type of the login field as well as the autocomplete setting. mark_input_fields_with_autocomplete? :: Whether input fields should be marked with autocomplete attribute appropriate for the field, true by default. mark_input_fields_with_inputmode? :: Whether input fields should be marked with inputmode attribute appropriate for the field, true by default. max_param_bytesize :: The maximum bytesize allowed for submitted parameters, 1024 by default. Use nil for no limit. modifications_require_password? :: Whether making changes to an account requires the user reinputing their password. True by default if the account has a password. no_matching_login_error_status :: The response status to use when the login is not in the database, 401 by default. no_matching_login_message :: The error message to display when the login used is not in the database. password_hash_column :: The password hash column in the +password_hash_table+. password_hash_id_column :: The account id column in the +password_hash_table+. password_hash_table :: The table storing the password hashes. password_label :: The label to use for passwords. password_param :: The parameter name to use for passwords. require_login_error_flash :: The flash error to display when accessing a page that requires a login, when you are not logged in. require_login_redirect :: A redirect to the login page. set_deadline_values? :: Whether deadline values should be set. True by default on MySQL, as that doesn't support default values that are not constant. Can be set to true on other databases if you want to vary the value based on a request parameter. strftime_format :: The format to pass to Time#strftime when formatting timestamps to display to the user, '%F %T' by default. template_opts :: Any template options to pass to view/render. This can be used to set a custom layout, for example. token_separator :: The string used to separate account id from the random key in links. unmatched_field_error_status :: The response status to use when two field values should match but do not, 422 by default. unopen_account_error_status :: The response status to use when trying to login to an account that isn't open, 403 by default. use_database_authentication_functions? :: Whether to use functions to do authentication. True by default on PostgreSQL, MySQL, and Microsoft SQL Server, false otherwise. use_date_arithmetic? :: Whether the date_arithmetic extension should be loaded into the database. Defaults to whether deadline values should be set. use_request_specific_csrf_tokens? :: Whether to use request-specific CSRF tokens. True if the default CSRF setting are used. use_template_fixed_locals? :: Whether to specify fixed locals for rodauth templates. True by default, should only be set to false if overriding the templates and having them accept different local variables. == Auth Methods account_from_id(id, status_id=nil) :: Retrieve the account hash for the given account id and status. account_from_login(login) :: Retrieve the account hash related to the given login or nil if no login matches. account_from_session :: Retrieve the account hash related to the currently logged in session. account_id :: The primary key value of the current account. account_session_value :: The primary value of the current account to store in the session when logging in. after_login :: Run arbitrary code after a successful login. after_login_failure :: Run arbitrary code after a login failure due to an invalid password. already_logged_in :: What action to take if you are already logged in and attempt to access a page that only makes sense if you are not logged in. around_rodauth(&block) :: Run arbitrary code around handling any rodauth route. Call super(&block) for Rodauth to handle the action. authenticated? :: Whether the user has been authenticated. If multifactor authentication has been enabled for the account, this is true only if the session is multifactor authenticated. before_login :: Run arbitrary code after password has been checked, but before updating the session. before_login_attempt :: Run arbitrary code after an account has been located, but before the password has been checked. before_rodauth :: Run arbitrary code before handling any rodauth route, but after CSRF checks if Rodauth is doing CSRF checks. check_csrf :: Checks CSRF token using Roda's +check_csrf!+ method. clear_session :: Clears the current session. convert_token_id(id) :: Convert the token id string to an appropriate object to use for the token id (or return +nil+ to signal an invalid token id). By default, converts to a 64-bit signed integer if +convert_token_id_to_integer?+ is true. csrf_tag(path=request.path) :: The HTML fragment containing the CSRF tag to use, if any. function_name(name) :: The name of the database function to call. It's passed either :rodauth_get_salt or :rodauth_valid_password_hash. logged_in? :: Whether the current session is logged in. login_required :: Action to take when a login is required to access the page and the user is not logged in. normalize_login(login) :: How to normalize the submitted login parameter value, returns the argument by default. null_byte_parameter_value(key, value) :: The value to use for the parameter if the parameter includes an ASCII NUL byte ("\0"), nil by default to ignore the parameter. open_account? :: Whether the current account is an open account (not closed or unverified). over_max_bytesize_param_value(key, value) :: The value to use for the parameter if the parameter is over the maximum allowed bytesize, nil by default to ignore the parameter. password_match?(password) :: Check whether the given password matches the stored password hash. random_key :: A randomly generated string, used for creating tokens. redirect(path) :: Redirect the request to the given path. session_value :: The value for session_key in the current session. set_error_flash(message) :: Set the current error flash to the given message. set_error_reason(reason) :: You can override this method to customize handling of specific error types (does nothing by default). Each separate error type has a separate reason symbol, you can see the {list of error reason symbols}[rdoc-ref:doc/error_reasons.rdoc]. set_notice_flash(message) :: Set the next notice flash to the given message. set_notice_now_flash(message) :: Set the current notice flash to the given message. set_redirect_error_flash(message) :: Set the next error flash to the given message. set_title(title) :: Set the title of the page to the given title. translate(key, default_value) :: Return a translated version for the key (uses the default value by default). unverified_account_message :: The message to use when attempting to login to an unverified account. update_session :: Clear the session, then set the session key to the primary key of the current account. jeremyevans-rodauth-b53f402/doc/change_login.rdoc000066400000000000000000000030021515725514200220720ustar00rootroot00000000000000= Documentation for Change Login Feature The change login feature implements a form that a user can use to change their login. == Auth Value Methods change_login_additional_form_tags :: HTML fragment containing additional form tags to use on the change login form. change_login_button :: The text to use for the change login button. change_login_error_flash :: The flash error to show for an unsuccessful login change. change_login_notice_flash :: The flash notice to show after a successful login change. change_login_page_title :: The page title to use on the change login form. change_login_redirect :: Where to redirect after a successful login change. change_login_requires_password? :: Whether a password is required when changing logins. change_login_route :: The route to the change login action. Defaults to +change-login+. same_as_current_login_message :: The error message to display if using the same value as the current login when changing the login. == Auth Methods after_change_login :: Run arbitrary code after successful login change. before_change_login :: Run arbitrary code before changing a login. before_change_login_route :: Run arbitrary code before handling a change login route. change_login(login) :: Change the users login to the given login, or return nil/false if the login cannot be changed to the given login. change_login_response :: Return a response after a successful login change. By default, redirects to +change_login_redirect+. change_login_view :: The HTML to use for the change login form. jeremyevans-rodauth-b53f402/doc/change_password.rdoc000066400000000000000000000031561515725514200226360ustar00rootroot00000000000000= Documentation for Change Password Feature The change password feature implements a form that a user can use to change their password. == Auth Value Methods change_password_additional_form_tags :: HTML fragment containing additional form tags to use on the change password form. change_password_button :: The text to use for the change password button. change_password_error_flash :: The flash error to show for an unsuccessful password change. change_password_notice_flash :: The flash notice to show after a successful password change. change_password_page_title :: The page title to use on the change password form. change_password_redirect :: Where to redirect after a successful password change. change_password_requires_password? :: Whether a password is required when changing passwords. change_password_route :: The route to the change password action. Defaults to +change-password+. invalid_previous_password_message :: The message to use when the previous password was incorrect. Defaults to +invalid_password_message+. new_password_label :: The label to use for the new password. new_password_param :: The parameter name to use for new passwords. == Auth Methods after_change_password :: Run arbitrary code after successful password change. before_change_password :: Run arbitrary code before changing the password for an account. before_change_password_route :: Run arbitrary code before handling a change password route. change_password_response :: Return a response after a successful password change. By default, redirects to +change_password_redirect+. change_password_view :: The HTML to use for the change password form. jeremyevans-rodauth-b53f402/doc/change_password_notify.rdoc000066400000000000000000000010101515725514200242110ustar00rootroot00000000000000= Documentation for Change Password Notify Feature The change password notify feature emails the user when their password is changed using the change password feature. == Auth Value Methods password_changed_email_body :: Body to use for the password changed emails password_changed_email_subject :: Subject to use for the password changed emails == Auth Methods create_password_changed_email :: A Mail::Message for the password changed email to send. send_password_changed_email :: Send the password changed email. jeremyevans-rodauth-b53f402/doc/close_account.rdoc000066400000000000000000000031551515725514200223070ustar00rootroot00000000000000= Documentation for Close Account Feature The close account feature allows users to close their accounts. == Auth Value Methods account_closed_status_value :: The integer representing closed accounts. close_account_additional_form_tags :: HTML fragment containing additional form tags to use on the close account form. close_account_button :: The text to use for the close account button. close_account_error_flash :: The flash error to show if there is an error closing the account. close_account_notice_flash :: The flash notice to show after closing the account. close_account_page_title :: The page title to use on the close account form. close_account_redirect :: Where to redirect after closing the account. close_account_requires_password? :: Whether a password is required when closing accounts. close_account_route :: The route to the close account action. Defaults to +close-account+. delete_account_on_close? :: Whether to delete the account when closing it, default value is to use +skip_status_checks?+. == Auth Methods after_close_account :: Run arbitrary code after closing the account. before_close_account :: Run arbitrary code before closing an account. before_close_account_route :: Run arbitrary code before handling a close account route. close_account :: Close the account, by default setting the account status to closed. close_account_response :: Return a response after successfully closing the account . By default, redirects to +close_account_redirect+. close_account_view :: The HTML to use for the close account form. delete_account :: If +delete_account_on_close?+ is true, delete the account when closing it. jeremyevans-rodauth-b53f402/doc/confirm_password.rdoc000066400000000000000000000044511515725514200230450ustar00rootroot00000000000000= Documentation for Confirm Password Feature The confirm password feature allows you to redirect users to a page to confirm their password. When confirming passwords, if authenticated via autologin, a remember token, or an email_auth token, switches the authentication type from that login method to password. == Auth Value Methods confirm_password_additional_form_tags :: HTML fragment containing additional form tags to use on the confirm password form. confirm_password_button :: The text to use for the confirm password button. confirm_password_error_flash :: The flash error to show if password confirmation is unsuccessful. confirm_password_link_text :: The text to use for the link from the two factor auth page. confirm_password_notice_flash :: The flash notice to show after password confirmed successful. confirm_password_page_title :: The page title to use on the confirm password form. confirm_password_redirect :: Where to redirect after successful password confirmation. By default, uses session[confirm_password_redirect_session_key] if set, allowing an easy way to redirect back to the page requesting password confirmation. confirm_password_redirect_session_key :: The session key used to check for the confirm_password_redirect. confirm_password_route :: The route to the confirm password form. Defaults to +confirm-password+. password_authentication_required_error_flash :: The flash error to show if going to a page requiring password confirmation. password_authentication_required_error_status :: The response status to use if going to a page requiring password confirmation, 401 by default. password_authentication_required_redirect :: Where to redirect when going to a page requiring password confirmation. == Auth Methods after_confirm_password :: Run arbitrary code after successful confirmation of password. before_confirm_password :: Run arbitrary code before setting that the password has been confirmed. before_confirm_password_route :: Run arbitrary code before handling the password confirmation route. confirm_password :: Update the session to reflect the password has been confirmed. confirm_password_response :: Return a response after successful password confirmation. By default, redirects to +confirm_password_redirect+. confirm_password_view :: The HTML to use for the confirm password form. jeremyevans-rodauth-b53f402/doc/create_account.rdoc000066400000000000000000000037331515725514200224470ustar00rootroot00000000000000= Documentation for Create Account Feature The create account feature allows users to create new accounts. == Auth Value Methods create_account_additional_form_tags :: HTML fragment containing additional form tags to use on the create account form. create_account_button :: The text to use for the create account button. create_account_error_flash :: The flash error to show for unsuccessful account creation. create_account_notice_flash :: The flash notice to show after successful account creation. create_account_page_title :: The page title to use on the create account form. create_account_redirect :: Where to redirect after creating the account. create_account_route :: The route to the create account action. Defaults to +create-account+. create_account_set_password? :: Whether to ask for a password to be set on the create account form. Defaults to true if not verifying accounts. If set to false, an alternative method to set the password should be used (assuming you want to allow password authentication). == Auth Methods after_create_account :: Run arbitrary code after creating the account. before_create_account :: Run arbitrary code before creating the account. before_create_account_route :: Run arbitrary code before handling a create account route. create_account_autologin? :: Whether to autologin the user upon successful account creation, true by default unless verifying accounts. create_account_link_text :: The text to use for a link to the create account form. create_account_response :: Return a response after successful account creation. By default, redirects to +create_account_redirect+. create_account_view :: The HTML to use for the create account form. new_account(login) :: Instantiate a new account hash for the given login, without saving it. save_account :: Insert the account into the database, or return nil/false if that was not successful. set_new_account_password :: Set the password for a new account if +account_password_hash_column+ is set, without saving. jeremyevans-rodauth-b53f402/doc/disallow_common_passwords.rdoc000066400000000000000000000026251515725514200247620ustar00rootroot00000000000000= Documentation for Disallow Common Passwords Feature The disallow common passwords feature disallows setting of a password that matches one of the most common passwords. By default, a list of 10,000 of the most common passwords is used, but you can supply your own file. Using a larger list is recommended, but Rodauth doesn't ship with a larger list to avoid bloating the size of the gem. == Auth Value Methods most_common_passwords :: An object that responds to +include?+ which will return true if the password given is one of the most common passwords. Useful for custom password sets where they are not stored in files and kept in memory. most_common_passwords_file :: The path to the file containing the most common passwords, which are not allowed to be used for new passwords. Defaults to a list of 10,000 most common passwords that ships with Rodauth. Can be set to nil/false if you do not want to to load common passwords from a file. password_is_one_of_the_most_common_message :: The error message fragment to display if the given password matches one of the most common passwords. == Auth Methods password_one_of_most_common?(password) :: This can be used to override the default check for whether the given password is contained in the most_common_passwords_file. This method may be useful when using very large password databases where you don't want to keep the list of most common passwords in memory. jeremyevans-rodauth-b53f402/doc/disallow_password_reuse.rdoc000066400000000000000000000033431515725514200244300ustar00rootroot00000000000000= Documentation for Disallow Password Reuse Feature The disallow password reuse feature disallows setting of a password that matches a number of previous passwords (6 by default). On databases where Rodauth supports the use of database authentication functions, Rodauth also supports the use of database functions for checking previous passwords, so previous password hashes enjoy the same database security as current password hashes. It is not recommended to use this feature unless you have a policy that requires it. This will significantly slow down setting a new password due to the need to check all of the previous stored passwords. Additionally, storing previous passwords means that if attackers can get access to the the database, they can get the previous stored passwords in addition to the current password. == Auth Value Methods password_same_as_previous_password_message :: The error message fragment to display if the given password is the same as a previous password. previous_password_account_id_column :: The column in the +previous_password_hash_table+ that stores the account id. previous_password_hash_column :: The column in the +previous_password_hash_table+ that stores the password hash. previous_password_hash_table :: The table storing previous password hashes. previous_password_id_column :: The column in the +previous_password_hash_table+ that stores the autoincrementing primary key. previous_passwords_to_check :: The number of previous password hashes to store and check. == Auth Methods add_previous_password_hash(hash) :: Add the given hash to the list of previous hashes for the current account. password_doesnt_match_previous_password?(password) :: Whether the password given matches any of the previous passwords. jeremyevans-rodauth-b53f402/doc/email_auth.rdoc000066400000000000000000000115321515725514200215740ustar00rootroot00000000000000= Documentation for Email Auth Feature The email auth feature implements passwordless login using links sent via email. It is similar to the reset password feature, except you don't need to update a password, or even have a password to login. It depends on the login and email_base features. == Auth Value Methods email_auth_additional_form_tags :: HTML fragment containing additional form tags to use on the email auth login form. email_auth_deadline_column :: The column name in the +email_auth_table+ storing the deadline after which the token will be ignored. email_auth_deadline_interval :: The amount of time for which to allow users to use email auth keys, 1 day by default. Only used if set_deadline_values? is true. email_auth_email_last_sent_column :: The email auth last sent column in the +email_auth_table+, storing the last time the email was sent. Set to nil to always send an email when requested. email_auth_email_recently_sent_error_flash :: The flash error to show if not sending an email auth email because another was sent recently. email_auth_email_recently_sent_redirect :: Where to redirect after not sending an email auth email because another was sent recently. email_auth_email_sent_notice_flash :: The flash notice to show after an email auth email has been sent. email_auth_email_sent_redirect :: Where to redirect after sending an email auth email. email_auth_email_subject :: The subject to use for email auth emails. email_auth_error_flash :: The flash error to show if unable to login using email authentication. email_auth_id_column :: The id column in the +email_auth_table+, should be a foreign key referencing the accounts table. email_auth_key_column :: The email auth key/token column in the +email_auth_table+. email_auth_key_param :: The parameter name to use for the email auth key. email_auth_page_title :: The page title to use on the email auth form. email_auth_request_additional_form_tags :: HTML fragment containing additional form tags to use on the email auth request form. email_auth_request_button :: The text to use for the email auth request button. email_auth_request_error_flash :: The flash error to show if not able to send an email auth email. email_auth_request_route :: The route to the email auth request action. Defaults to +email-auth-request+. email_auth_route :: The route to the email auth action. Defaults to +email-auth+. email_auth_session_key :: The key in the session to hold the email auth key temporarily. email_auth_skip_resend_email_within :: The number of seconds required before sending another email auth email, 5 minutes by default. email_auth_table :: The name of the table storing email auth keys. force_email_auth? :: Whether email auth should be forced for the account. False by default, which results in email auth only be used automatically if the account does not have a password. no_matching_email_auth_key_error_flash :: The flash error message to show if attempting to access the email auth form with an invalid key. == Auth Methods account_from_email_auth_key(key) :: Retrieve the account using the given email auth key, or return nil if no account matches. after_email_auth_request :: Run arbitrary code after sending the email auth email. before_email_auth_request :: Run arbitrary code before sending the email auth email. before_email_auth_request_route :: Run arbitrary code before handling an email auth request route. before_email_auth_route :: Run arbitrary code before handling an email auth route. create_email_auth_email :: A Mail::Message for the email auth email. create_email_auth_key :: Add the email auth key data to the database. email_auth_email_body :: The body to use for the email auth email. email_auth_email_link :: The link to the email auth form in the email auth email. email_auth_email_sent_response :: Return a response after successfully sending an email auth email. By default, redirects to +email_auth_email_sent_redirect+. email_auth_key_insert_hash :: The hash to insert into the +email_auth_table+. email_auth_key_value :: The email auth key for the current account. email_auth_request_form :: The HTML to use for a form to request an email auth email, shown on the login page after the user submits their login, if +force_email_auth?+ is false and email authentication is not the only possible for of authentication for the user. email_auth_view :: The HTML to use for the email auth form. get_email_auth_email_last_sent :: Get the last time an email auth email is sent, or nil if there is no last sent time. get_email_auth_key(id) :: Get the email auth key for the given account id from the database. remove_email_auth_key :: Remove the email auth key for the current account, run after successful email auth. send_email_auth_email :: Send the email auth email. set_email_auth_email_last_sent :: Set the last time an email auth email is sent. This is only called if there is a previous email auth token still active. jeremyevans-rodauth-b53f402/doc/email_base.rdoc000066400000000000000000000023611515725514200215450ustar00rootroot00000000000000= Documentation for Email Base Feature The email base feature is automatically loaded when you use a Rodauth feature that requires sending emails. == Auth Value Methods allow_raw_email_token? :: When +hmac_secret+ is used, this allows the use of the raw token. This should only be set to true temporarily during a transition period from using raw tokens to using HMACed tokens. After the transition period, this should not be set, as setting this to true removes the security that HMACed tokens add. default_post_email_redirect :: Where to redirect after sending an email. This is the default redirect location for all redirects after an email is sent when the account is not logged in. Also includes cases where an email is not sent due to rate limiting. email_from :: The from address to use for emails sent by Rodauth. email_subject_prefix :: The prefix to use for email subjects require_mail? :: Set to false to not require mail, useful if using a different library for sending email. == Auth Methods create_email(subject, body) :: Return a Mail::Message instance with the given subject and body. email_to :: The email address to send emails to, by default the login of the current account. send_email(email) :: Deliver a given Mail::Message instance. jeremyevans-rodauth-b53f402/doc/error_reasons.rdoc000066400000000000000000000045051515725514200223510ustar00rootroot00000000000000= Error Reasons Rodauth allows for customizing response status codes and error messages for each type of error. However, in some cases, the response status code is too coarse for desired error handling by the application (since many error types use the same status code), and using the error message is too fragile since it may be translated. For this reason, Rodauth associates a fine grained reason for each type of error. If an error occurs in Rodauth, it will call the +set_error_reason+ method with a symbol for the specific type of error. By default, this method does not do anything, but you can use the +set_error_reason+ configuration method to customize the error handling. These are the currently supported error type symbols that Rodauth will call +set_error_reason+ with: * :account_locked_out * :already_an_account_with_this_login * :already_an_unverified_account_with_this_login * :duplicate_webauthn_id * :inactive_session * :invalid_email_auth_key * :invalid_otp_auth_code * :invalid_otp_secret * :invalid_password * :invalid_password_pattern * :invalid_phone_number * :invalid_previous_password * :invalid_recovery_code * :invalid_remember_param * :invalid_reset_password_key * :invalid_sms_code * :invalid_sms_confirmation_code * :invalid_unlock_account_key * :invalid_verify_account_key * :invalid_verify_login_change_key * :invalid_webauthn_auth_param * :invalid_webauthn_id * :invalid_webauthn_remove_param * :invalid_webauthn_setup_param * :invalid_webauthn_sign_count * :login_not_valid_email * :login_required * :login_too_long * :login_too_many_bytes * :login_too_short * :logins_do_not_match * :no_current_sms_code * :no_matching_login * :not_enough_character_groups_in_password * :otp_locked_out * :password_authentication_required * :password_contains_null_byte * :password_does_not_meet_requirements * :password_in_dictionary * :password_is_one_of_the_most_common * :password_same_as_previous_password * :password_too_long * :password_too_many_bytes * :password_too_short * :passwords_do_not_match * :same_as_current_login * :same_as_existing_password * :session_expired * :sms_already_setup * :sms_locked_out * :sms_needs_confirmation * :sms_not_setup * :too_many_repeating_characters_in_password * :two_factor_already_authenticated * :two_factor_need_authentication * :two_factor_not_setup * :unverified_account * :webauthn_not_setup jeremyevans-rodauth-b53f402/doc/guides/000077500000000000000000000000001515725514200200715ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/doc/guides/admin_activation.rdoc000066400000000000000000000034011515725514200242510ustar00rootroot00000000000000= Require account verification by admin There are scenarios in which, instead of allowing the user to verify they have access to the email for the account, you may want to have an admin or moderator approve new accounts manually. One way this can be achieved by sending the account verification email to the admin: plugin :rodauth do enable :login, :logout, :verify_account, :reset_password # Send account verification email to the admin email_to do if account[account_status_column] == account_unverified_status_value "admin@myapp.com" else super() end end # Do not ask for password when creating or verifying account verify_account_set_password? false create_account_set_password? false # Adjust the account verification email subject and body verify_account_email_subject "New User Awaiting Admin Approval" verify_account_email_body do "The user #{account[login_column]} has created an account. Click here to approve it: #{verify_account_email_link}." end # Display this message to the user after they've created their account verify_account_email_sent_notice_flash "Your account has been created and is awaiting approval" # Prevent the admin from being logged in after confirming the account verify_account_autologin? false verify_account_notice_flash "The account has been approved" # Send a reset password email after verifying the account. # This allows the user to choose the password for the account, # and also makes sure the user can only log in if they have # access to the email address for the account. after_verify_account do generate_reset_password_key_value create_reset_password_key send_reset_password_email end end jeremyevans-rodauth-b53f402/doc/guides/already_authenticated.rdoc000066400000000000000000000006431515725514200252700ustar00rootroot00000000000000= Skip login page if already authenticated In some cases it may be useful to skip login/registration pages when the user is already logged in. This can be achieved as follows. Note that this only matters if the user manually navigates to the login or create account pages. plugin :rodauth do # Redirect logged in users to the wherever login redirects to already_logged_in { redirect login_redirect } end jeremyevans-rodauth-b53f402/doc/guides/alternative_login.rdoc000066400000000000000000000032161515725514200244520ustar00rootroot00000000000000= Use a non-email login Rodauth's by default uses email addresses for identifying users, since that is the most common form of identifier currently. In some cases, you might want to allow logging in via alternative identifiers, such as a username. In this case, it is best to choose a different column name for the login, such as +:username+. Among other things, this also makes it so that the login field does not expect an email address to be provided. plugin :rodauth do enable :login, :logout login_column :username end Note that Rodauth features that require sending email need an email address, and that defaults to the value of the login column. If you have both a username and an email for an account, you can have the login column be the user, and use the value of the email column for the email address. plugin :rodauth do enable :login, :logout, :reset_password login_column :username email_to do account[:email] end end An alternative approach would be to accept a login and automatically change it to an email address. If you have a +username+ field on the +accounts+ table, then you can configure Rodauth to allow entering a username instead of email during login. See the {Adding new registration field}[rdoc-ref:doc/guides/registration_field.rdoc] guide for instructions on requiring add an additional field during registration. plugin :rodauth do enable :login, :logout account_from_login do |login| # handle the case when login parameter is a username unless login.include?("@") login = db[:accounts].where(username: login).get(:email) end super(login) end end jeremyevans-rodauth-b53f402/doc/guides/case_insensitive_login.rdoc000066400000000000000000000011641515725514200254670ustar00rootroot00000000000000= Case insensitive logins If your database schema doesn't support case insensitive logins, you can tell Rodauth to automatically lowercase login param values during authentication and persistence via the +normalize_login+ configuration option: normalize_login(&:downcase) Of the four database types Rodauth officially supports (PostgreSQL, MySQL, Microsoft SQL Server, and SQLite), only SQLite does not support a case insensitive column for storing logins by default. However, other databases could be configured to not use a case insensitive column for logins by default, in which case you would want to use this setting. jeremyevans-rodauth-b53f402/doc/guides/change_table_and_column_names.rdoc000066400000000000000000000015361515725514200267250ustar00rootroot00000000000000= Change table and column names All tables that Rodauth uses will have a configuration method that ends with +_table+ for configuring the table name. For example, if you store user accounts in the +users+ table instead of +accounts+ table, you can use the following in your configuration: accounts_table :users All columns that Rodauth uses will have a configuration method that ends with +_column+ for configuring the column name. For example, if you are storing the login for accounts in the +login+ column instead of the +email+ column, you can use the following in your configuration: login_column :login Please see the documentation for Rodauth features for the names of the configuration methods that you can use. You can see the default values for the tables and columns in the {"Creating tables" section of the README}[rdoc-ref:README.rdoc]. jeremyevans-rodauth-b53f402/doc/guides/create_account_programmatically.rdoc000066400000000000000000000021461515725514200273530ustar00rootroot00000000000000= Create an account record programmatically In some scenarios you might want to create an account records programmatically, for example in your tests. If you're storing passwords in a separate table, you can create an account records as follows: account_id = DB[:accounts].insert( email: "name@example.com", status_id: 2, # verified ) DB[:account_password_hashes].insert( id: account_id, password_hash: BCrypt::Password.create("secret").to_s, ) If the password is stored in a column in the accounts table: account_id = DB[:accounts].insert( email: "name@example.com", password_hash: BCrypt::Password.create("secret").to_s, status_id: 2, # verified ) If you are creating accounts in your tests, you probably want to use the +:cost+ option, otherwise you will have very slow tests: account_id = DB[:accounts].insert( email: "name@example.com", status_id: 2, # verified ) DB[:account_password_hashes].insert( id: account_id, password_hash: BCrypt::Password.create("secret", cost: BCrypt::Engine::MIN_COST).to_s, ) jeremyevans-rodauth-b53f402/doc/guides/delay_password.rdoc000066400000000000000000000017441515725514200237700ustar00rootroot00000000000000= Set password when verifying account If you want to request less information from the user on registration, you can ask the user to set their password only when they verify their account: plugin :rodauth do enable :login, :logout, :verify_account verify_account_set_password? true end Note that this is already the default behaviour when verify account feature is loaded, but it's not when verify account grace period is used, because it would prevent the account from logging in during the grace period. You can work around this by automatically remembering their login during account creation using the remember feature. Be aware that remembering accounts has effects beyond the verification period, and this would only allow automatic logins from the browser that created the account. plugin :rodauth do enable :login, :logout, :verify_account_grace_period, :remember verify_account_set_password? true after_create_account do remember_login end end jeremyevans-rodauth-b53f402/doc/guides/email_only.rdoc000066400000000000000000000010331515725514200230670ustar00rootroot00000000000000= Allow only email authentication When using the email authentication feature, you can avoid other authentication mechanisms entirely as follows: plugin :rodauth do enable :login, :email_auth, :create_account, :verify_account create_account_set_password? false verify_account_set_password? false force_email_auth? true end With this configuration, users won't be required to enter a password on registration, and on login the email authentication link will automatically be sent after the email address is entered. jeremyevans-rodauth-b53f402/doc/guides/email_requirements.rdoc000066400000000000000000000016671515725514200246460ustar00rootroot00000000000000= Customize email requirements By default, Rodauth requires emails to have at least 3 characters and at most 255 bytes. You can modify the minimum and maximum length: plugin :rodauth do enable :login, :logout, :create_account # Require emails to have at least 5 characters login_minimum_length 5 # Don't allow emails longer than 100 characters login_maximum_length 100 # Don't allow emails larger than 200 bytes login_maximum_bytes 200 end You can also override email address validation, and do more advanced email checks, such as checking whether the email address exists using the {Truemail}[https://github.com/truemail-rb/truemail] gem: require "truemail" Truemail.configure do |config| config.verifier_email = "verifier@example.com" end plugin :rodauth do enable :login, :logout, :create_account login_valid_email? do |email| super(email) && Truemail.valid?(email) end end jeremyevans-rodauth-b53f402/doc/guides/i18n.rdoc000066400000000000000000000017571515725514200215330ustar00rootroot00000000000000= Translate with i18n gem Rodauth allows transforming user-facing text configuration such as flash messages, validation errors, labels etc. via the +translate+ configuration method. This method receives a name of a configuration along with its default value, and is expected to return the result text. You can use this to perform translations using the {i18n gem}[https://github.com/ruby-i18n/i18n]: plugin :rodauth do enable :login, :logout, :reset_password translate do |key, default| I18n.translate("rodauth.#{key}") || default end end Your translation file may then look something like this: en: rodauth: login_notice_flash: "You have been signed in" require_login_error_flash: "Login is required for accessing this page" no_matching_login_message: "user with this email address doesn't exist" reset_password_email_subject: "Password Reset Instructions" Alternatively, you can use the {rodauth-i18n}[https://github.com/janko/rodauth-i18n] gem. jeremyevans-rodauth-b53f402/doc/guides/internals.rdoc000066400000000000000000000264601515725514200227510ustar00rootroot00000000000000= Rodauth Internals Rodauth's implementation heavily uses metaprogramming in order to DRY up the codebase, which can be a little intimidating to developers who are not familiar with the codebase. This guide explains how Rodauth is built, which should make the internals easier to understand. == Object Model First, let's talk about the basic parts of Rodauth. === Rodauth::Auth Rodauth::Auth is the core of rodauth. If a user calls +rodauth+ inside their Roda application, they get a Rodauth::Auth subclass instance. Rodauth's configuration DSL is designed to build a Rodauth::Auth subclass appropriate to the application, by loading only the features that are needed, and overriding defaults as appropriate. === Rodauth::Configuration Inside the block you pass to plugin :rodauth, +self+ is an instance of this class. This class is mostly empty, as most of Rodauth is implemented as separate features, and the configuration for each feature is loaded as a separate module into this instance. === Rodauth::Feature Each of the parts of rodauth that you can use is going to be a separate feature. Rodauth::Feature is a Module subclass, and every rodauth feature you load is an instance of this class, which is included in the Rodauth::Auth subclass used by the Roda application. Rodauth::Feature has many methods designed to make building Rodauth features easier by defining methods in the Rodauth::Feature instance. === Rodauth::FeatureConfiguration Just as each feature is a module included in the Rodauth::Auth subclass for the application, each feature also contains a configuration module that is an instance of Rodauth::FeatureConfiguration (also a module subclass). For each feature you load into the Rodauth configuration, the Rodauth::Configuration instance is extended with the feature's Rodauth::FeatureConfiguration instance, which is what makes the feature's configuration methods available inside the plugin :rodauth block. This is why you need to enable the features in Rodauth before configuring them. == Object Model Example Here's some commented output hopefully showing the relation between the different parts Roda.plugin :rodauth do self # => # (instance) auth # => Rodauth::Auth subclass singleton_class.ancestors # => [#> (singleton class of self), # Rodauth::FeatureConfiguration::Base (instance of Rodauth::FeatureConfiguration), # Rodauth::Configuration, # ...] auth.ancestors # => [Rodauth::Auth subclass, # Rodauth::Base (instance of Rodauth::Feature), # Rodauth::Auth, # ...] enable :login singleton_class.ancestors # => [#> (singleton class of self), # Rodauth::FeatureConfiguration::Login (instance of Rodauth::FeatureConfiguration), # Rodauth::FeatureConfiguration::Base (instance of Rodauth::FeatureConfiguration), # Rodauth::Configuration, # ...] auth.ancestors # => [Rodauth::Auth subclass, # Rodauth::Login (instance of Rodauth::Feature), # Rodauth::Base (instance of Rodauth::Feature), # Rodauth::Auth, # ...] end Roda.rodauth # => Rodauth::Auth subclass Roda.rodauth.ancestors # => [Rodauth::Auth subclass, # Rodauth::Login (instance of Rodauth::Feature), # Rodauth::Base (instance of Rodauth::Feature), # Rodauth::Auth, # ...] Roda.route do |r| rodauth # => Rodauth::Auth subclass instance end == Feature Creation Example Here's a heavily commented example showing what is going on inside a Rodauth feature. module Rodauth # Feature.define takes a symbol, specifying the name of the feature. This # is the same symbol you would pass to enable when loading the feature into # the Rodauth configuration. Feature is a module subclass, and Feature.define # is a class method that creates an instance of Feature (a module) and executes # the block in the context of the Feature instance. # # The second argument is optional, and sets the Feature instance and related # FeatureConfiguration instance to a constant in the Rodauth namespace, which # makes it easier to locate via inspect. Feature.define(:foo, :Foo) do # Inside this block, self is an instance of Feature. As this instance of # Feature will be included in the Rodauth::Auth subclass instance if # the feature is loaded into the rodauth configuration, methods you define # in this block (via def or define_method) will be callable on any # rodauth object if this feature is loaded into the rodauth configuration. # Feature has many instance methods that define methods in the Feature # instance. This is one of those methods, which sets the text of the notice # flash, shown after successful submission of the form. It's basically # equivalent to executing this code in the feature: # # def foo_notice_flash # "It worked!" # end # # while also adding a method to the configuration which does: # # def foo_notice_flash(v=nil, &block) # block ||= proc{v} # @auth.class_eval do # define_method(:foo_notice_flash, &block) # end # end # # This is what easily allows you to modify any part of Rodauth during # configuration. The Rodauth::Auth subclass has the default behavior # added via a method in an included module (the Feature instance), and the # Rodauth::Configuration instance has a method that when called defines # a method in the Rodauth::Auth subclass itself, which will take precedence # over the default method, which defined in the included Feature instance. notice_flash "It worked!" # The rest of these method calls are fairly similar to notice_flash. # This defines the foo_error_flash method, for the error flash message to # show if the form submission wasn't successful. error_flash "There was an error" # This defines the foo_view method to use template 'foo.str' in the templates # folder, and set the title of the page to 'Foo'. view 'foo', 'Foo' # This defines the foo_additional_form_tags method, which would generally be called # inside the foo.str template. additional_form_tags # This defines the foo_button method, for the text to use on the submit button # for the form in foo.str. button 'Submit' # This defines the foo_redirect method, for where to redirect after successful submission # of the form. redirect # This defines the before_foo method, called before performing the foo action. before # This defines the after_foo method, called after successfully performing the foo action. after # This defines a loaded_templates method that calls super and adds 'foo' as one of the # templates. This is necessary for precompilation of templates to work. loaded_templates ['foo'] # This defines the following methods related to sending email: # # * foo_email_subject: uses given subject # * foo_email_body: renders foo-email template # * create_foo_email: creates Mail::Message using subject and body # * send_foo_email: sends created email # # The foo-email template should be included in the loaded_templates call to make sure # template precompilation works. email :foo, 'Foo Subject' # auth_value_method is a generic method that takes two arguments, a method to define # and a default value. It is similar to the methods above, except that it allows # arbitrary method names. The notice_flash, error_flash, button, and additional_form_tags # methods are actually defined in terms of this method. # # So this particular method defines a foo_error_status method that will return 401 by # default, but also adds a configuration method that allows you to override the default. auth_value_method :foo_error_status, 401 # This is similar to auth_value_method, but it only adds the configuration method. # Using this should only be done if you have defining the method in the feature # separately (see below). auth_value_methods :foo_bar # This is similar to auth_value_methods, but it changes the configuration method so that # a block is required and you cannot provide an argument. This is used for the cases # where a statically defined value would never make sense, such as when any correct # behavior would depend on accessing request-specific information. auth_methods :foo # route defines a route used for the feature. This is the code that will be executed # if a user goes to /foo in the Roda app. route do |r| # Inside the block, you are in the context of the Rodauth::Auth subclass instance. # r is the Roda::RodaRequest subclass instance, just as it would be for a Roda # route block. # route adds a before_foo_route method that by default does nothing. It also # adds a configuration method that you can call to set behavior that will be # executed before routing. before_foo_route # Just like in Roda, r.get is called for GET requests r.get do # This will render a view to the user, using the foo.erb template from the # templates directory (unless the user has overridden it), inside the Roda # application's layout. foo_view end # Just like in Roda, r.post is called for POST requests r.post do # This is called before performing the foo action before_foo # This assumes foo returns false or nil on failure, or otherwise on # success. if foo # In general, Rodauth only calls after_foo if foo is successful. after_foo # Successful form submission will usually set the notice flash, # the redirect to the appropriate page. set_notice_flash foo_notice_flash redirect foo_redirect else # Unsuccessful form subsmission will usually set the error flash, # the redisplay the page so that the submission can be fixed. set_error_flash foo_error_flash foo_view end end end # This is the default behavior for the foo method, if a user doesn't # call the foo method inside the configuration block. def foo # Do Something end # This is the default behavior for the foo_bar method, if a user doesn't # call the foo_bar method inside the configuration block. def foo_bar 42 end end end jeremyevans-rodauth-b53f402/doc/guides/links.rdoc000066400000000000000000000007121515725514200220620ustar00rootroot00000000000000= Display authentication links You can retrieve a relative URL to any Rodauth action by calling the corresponding *_path method on the Rodauth instance: Sign in Sign up For absolute URLs instead of paths, you can use the *_url methods: Sign in Sign up jeremyevans-rodauth-b53f402/doc/guides/login_return.rdoc000066400000000000000000000023671515725514200234610ustar00rootroot00000000000000= Redirect to original page after login When the user attempts to open a page that requires authentication, Rodauth redirects them to the login page. It can be useful to redirect them back to the page they originally requested after successful login. Similarly, you can do this for pages requiring multifactor authentication. plugin :rodauth do enable :login, :logout, :otp # Have successful login redirect back to originally requested page login_return_to_requested_location? true # Have successful multifactor authentication redirect back to # originally requested page two_factor_auth_return_to_requested_location? true end You can manually set which page to redirect after login or multifactor authentication, though it is questionable whether the user will desire this behavior compared to the default. route do |r| r.rodauth # Return the last visited path after login if rodauth.logged_in? # Return to the last visited page after multifactor authentication unless rodauth.two_factor_authenticated? session[rodauth.two_factor_auth_redirect_session_key] = request.fullpath end else session[rodauth.login_redirect_session_key] = request.fullpath end # rest of routes end jeremyevans-rodauth-b53f402/doc/guides/migrate_password_hash_algorithm.rdoc000066400000000000000000000011441515725514200273650ustar00rootroot00000000000000= Migrate users passwords from bcrypt to argon2 or back If you are currently using the default bcrypt password hash algorithm, and want to gradually migrate to the argon2 password hash algorithm, you can use both the argon2 and update_password_hash features: plugin :rodauth do enable :login, :update_password_hash, :argon2 end When a user with a current bcrypt password hash next successfully uses their password, their password hash will be migrated to argon2. If for some reason you want to migrate back from argon2 to bcrypt, you can set use_argon2? false in your Rodauth configuration. jeremyevans-rodauth-b53f402/doc/guides/password_column.rdoc000066400000000000000000000014571515725514200241700ustar00rootroot00000000000000= Store password hash in accounts table By default, Rodauth stores the password hash in a separate +account_password_hashes+ table. This makes it a lot less likely that the password hashes will be leaked, especially if you use Rodauth's default approach of using database functions for checking the hashes. However, if you have reasons for storing the password hashes in +accounts+ table that outweigh the security benefits of Rodauth's default approach, Rodauth supports that. To do this, add the password hash column to the +accounts+ table: alter_table :accounts do add_column :password_hash, String end And then tell Rodauth to use it: plugin :rodauth do enable :login, :logout # Use the password_hash column in the accounts table account_password_hash_column :password_hash end jeremyevans-rodauth-b53f402/doc/guides/password_confirmation.rdoc000066400000000000000000000023051515725514200253540ustar00rootroot00000000000000= Require password confirmation for certain actions You might want to require the user to enter their password before accessing sensitive sections of the app. This functionality is provided by the confirm password feature, which accompanied with the password grace period feature will remember the entered password for a period of time: plugin :rodauth do enable :confirm_password, :password_grace_period # Remember the password for 1 hour password_grace_period 60*60 end route do |r| r.rodauth r.is 'some-action' do # Require password authentication if the password has not been # input recently. rodauth.require_password_authentication # ... end end You can also do this for Rodauth actions that normally require a password. Which essentially moves the password confirmation into a separate step, as Rodauth's behavior with the password grace period feature is to ask for the password on the same form. plugin :rodauth do enable :confirm_password, :password_grace_period, :change_login, :change_password before_change_login_route { require_password_authentication } before_change_password_route { require_password_authentication } end jeremyevans-rodauth-b53f402/doc/guides/password_requirements.rdoc000066400000000000000000000031161515725514200254100ustar00rootroot00000000000000= Customize password requirements By default, Rodauth requires passwords to have at least 6 characters. You can modify the minimum and maximum length: plugin :rodauth do enable :login, :logout, :create_account # Require passwords to have at least 8 characters password_minimum_length 8 # Don't allow passwords to be too long, to prevent long password DoS attacks password_maximum_length 64 end You can use the {disallow common passwords feature}[rdoc-ref:doc/disallow_common_passwords.rdoc] to prevent the usage of common passwords (the most common 10,000 by default). You can use additional complexity checks on passwords via the {password complexity feature}[rdoc-ref:doc/password_complexity.rdoc], though most of those complexity checks are no longer considered modern security best practices and are likely to decrease overall security. If you want complete control over whether passwords meet requirements, you can use the password_meets_requirements? configuration method. plugin :rodauth do enable :login, :logout, :create_account password_meets_requirements? do |password| super(password) && password_complex_enough?(password) end auth_class_eval do # If password doesn't pass custom validation, add field error with error # reason, and return false. def password_complex_enough?(password) return true if password.match?(/\d/) && password.match?(/[^a-zA-Z\d]/) set_password_requirement_error_message(:password_simple, "requires one number and one special character") false end end end jeremyevans-rodauth-b53f402/doc/guides/paths.rdoc000066400000000000000000000025641515725514200220700ustar00rootroot00000000000000= Change route path You can change the URL path of any Rodauth route by overriding the corresponding *_route method: plugin :rodauth do enable :login, :logout, :create_account, :reset_password # Change login route to "/signin" login_route "signin" # Change redirect when login is required to "/signin" require_login_redirect { login_path } # Change create account route to "/register" create_account_route "register" # Change password reset request route to "/reset-password/request" reset_password_request_route "reset-password/request" end If you want to add a prefix to all Rodauth routes, you should use the +prefix+ setting: plugin :rodauth do enable :login, :logout # Use /auth prefix to each Rodauth route prefix "/auth" end route do |r| r.on "auth" do # Serve Rodauth routes under the /auth branch of the routing tree r.rodauth end # ... end There are cases where you may want to disable certain routes. For example, you may want to enable the create_account feature to allow creating admins, but only make it possible programmatically via internal requests. In this case, you should set the corresponding *_route method to +nil+: plugin :rodauth, name: :admin do enable :create_account # disable the /create-account route create_account_route nil end jeremyevans-rodauth-b53f402/doc/guides/query_params.rdoc000066400000000000000000000004511515725514200234520ustar00rootroot00000000000000= Pass query parameters to auth URLs The *_path and *_url methods allow passing additional query parameters: rodauth.create_account_path(type: "seller") #=> "/create-account?type=seller" rodauth.login_url(type: "operator") #=> "https//example.com/login?type=operator" jeremyevans-rodauth-b53f402/doc/guides/redirects.rdoc000066400000000000000000000010221515725514200227210ustar00rootroot00000000000000= Change redirect destination You can change the redirect destination for any Rodauth action by overriding the corresponding *_redirect method: plugin :rodauth do enable :login, :logout, :create_account, :reset_password # Redirect to "/dashboard" after login login_redirect "/dashboard" # Redirect to wherever login redirects to after creating account create_account_redirect { login_redirect } # Redirect to login page after password reset reset_password_redirect { login_path } end jeremyevans-rodauth-b53f402/doc/guides/registration_field.rdoc000066400000000000000000000041441515725514200246220ustar00rootroot00000000000000= Add new field during account creation The create account form only handles login and password parameters by default. However, you might want to ask for additional information during account creation, such as requiring the user to also enter their full name or their company's name. == A) Accounts table Let's assume you wanted to wanted to store the additional field(s) directly on the +accounts+ table: alter_table :accounts do add_column :name, String end You need to override the create-account template, which by default in Rodauth you can do by adding a create-account.erb template in your Roda +views+ directory. Once you've added the create-account.erb template, and had it include a field for the +name+, you can handle the submission of that field in a before create account hook: plugin :rodauth do enable :login, :logout, :create_account before_create_account do # Validate presence of the name field. This example checks that the was field was submitted # and is not empty, but you may may want to have application specific checks. if param("name").empty? throw_error_status(422, "name", "must be present") end # Assign the new field to the account record account[:name] = param("name") end end == B) Separate table Alternatively, you can store the additional field(s) in separate table, for example: create_table :account_names do foreign_key :account_id, :accounts, primary_key: true, type: :Bignum String :name, null: false end You can then handle the new submitted field as follows: plugin :rodauth do enable :login, :logout, :create_account before_create_account do # Validate presence of the name field throw_error_status(422, "name", "must be present") if param("name").empty? end after_create_account do # Create the associated record db[:account_names].insert(account_id: account[:id], name: param("name")) end after_close_account do # Delete the associated record db[:account_names].where(account_id: account[:id]).delete end end jeremyevans-rodauth-b53f402/doc/guides/render_confirmation.rdoc000066400000000000000000000017301515725514200247720ustar00rootroot00000000000000= Render confirmation view Most Rodauth actions redirect and display a flash notice after they're successfully performed. However, in some cases you may wish to render a view confirming that the action was successful, for nicer user experience. For example, when the user creates an account, you might render a page with a call to action to verify their account. Assuming you've created an +account_created+ view template alongside your other Rodauth templates, you can configure the following: after_create_account do # render "account_created" view template with page title of "Account created!" return_response view("account_created", "Account created!") end Similarly, when the user has requested a password reset, you can render a page telling them to check their email: after_reset_password_request do # render "password_reset_sent" view template with page title of "Password sent!" return_response view("password_reset_sent", "Password sent!") end jeremyevans-rodauth-b53f402/doc/guides/require_mfa.rdoc000066400000000000000000000016141515725514200232430ustar00rootroot00000000000000= Require multifactor authentication after login You may want to require multifactor authentication on login for people that have multifactor authentication set up. The +require_authentication+ Rodauth method works for pages that require an authenticated user, but not for pages where authentication is optional. You can set this up as follows: plugin :rodauth do enable :login, :logout, :otp # If you don't want to show an error message when redirecting # to the multifactor authentication page. two_factor_need_authentication_error_flash nil # Display the same flash message after multifactor # authentication than is displayed after login two_factor_auth_notice_flash { login_notice_flash } end route do |r| r.rodauth if rodauth.logged_in? && rodauth.two_factor_authentication_setup? rodauth.require_two_factor_authenticated end # ... end jeremyevans-rodauth-b53f402/doc/guides/reset_password_autologin.rdoc000066400000000000000000000011751515725514200260730ustar00rootroot00000000000000= Autologin after password reset When the user resets their password, by default they are not automatically logged in. You can change this behaviour and login the user automatically after password reset. plugin :rodauth do enable :login, :logout, :reset_password reset_password_autologin? true end Similarly, when the verify login change feature is used, the user is not automatically logged in after verifying the login change. You can configure Rodauth to automatically log the user in in this case: plugin :rodauth do enable :login, :logout, :verify_login_change verify_login_change_autologin? true end jeremyevans-rodauth-b53f402/doc/guides/share_configuration.rdoc000066400000000000000000000017131515725514200247750ustar00rootroot00000000000000= Share configuration via inheritance If you have multiple configurations that needs to share some amount of authentication behaviour, you can do so through inheritance. For example: require "rodauth" class RodauthBase < Rodauth::Auth configure do # common authentication configuration end end class RodauthMain < RodauthBase # inherit common configuration configure do # main-specific authentication configuration end end class RodauthAdmin < RodauthBase # inherit common configuration configure do # admin-specific authentication configuration end end class RodauthApp < Roda plugin :rodauth, auth_class: RodauthMain plugin :rodauth, auth_class: RodauthAdmin, name: :admin # ... end However, when doing this, you need to be careful that you do not use a configuration method in a superclass, and then load a feature in a subclass that overrides the configuration you set in the superclass. jeremyevans-rodauth-b53f402/doc/guides/status_column.rdoc000066400000000000000000000020471515725514200236450ustar00rootroot00000000000000= Store account status in a text column By default, Rodauth recommends using a separate table for account statuses, and linking them via foreign keys. This is useful as it achieves an enum-like behaviour, where the database ensures a constrained set of status values. However, if you use a testing environment that starts with a blank database, and don't want to fix your testing environment to support real foreign keys, you can configure Rodauth to store the account status in a text column. Doing so results in problems if a text value you do not expect gets stored in the column. We can mitigate the problems by using a CHECK constraint on the column. create_table :accounts do # ... String :status, null: false, default: "verified", check: {status: %w'unverified verified closed'} end Then we can configure Rodauth to support this. plugin :rodauth do # ... account_status_column :status account_unverified_status_value "unverified" account_open_status_value "verified" account_closed_status_value "closed" end jeremyevans-rodauth-b53f402/doc/guides/totp_or_recovery.rdoc000066400000000000000000000010101515725514200243360ustar00rootroot00000000000000= Allow recovery code on TOTP code field If using the otp feature, for convenience you might want to allow the user to enter the recovery code into the TOTP code field, instead of requiring they use the separate recovery codes form. You can implement this using the following configuration: plugin :rodauth do enable :login, :logout, :otp, :recovery_codes before_otp_auth_route do if recovery_code_match?(param(otp_auth_param)) two_factor_authenticate("recovery_code") end end end jeremyevans-rodauth-b53f402/doc/http_basic_auth.rdoc000066400000000000000000000014761515725514200226330ustar00rootroot00000000000000= Documentation for HTTP Basic Auth Feature The HTTP basic auth feature allows logins using HTTP basic authentication, described in RFC 1945. In your routing block, you can require HTTP basic authentication via: rodauth.require_http_basic_auth If you want to allow HTTP basic authentication but not require it, you can call: rodauth.http_basic_auth == Auth Value Methods http_basic_auth_realm :: The realm to return in the WWW-Authenticate header. require_http_basic_auth? :: If true, when +rodauth.require_login+ or +rodauth.require_authentication+ is used, return a 401 status page if basic auth has not been provided, instead of redirecting to the login page. If false, +rodauth.require_login+ or +rodauth.require_authentication+ will check for HTTP basic authentication if not already logged in. False by default. jeremyevans-rodauth-b53f402/doc/internal_request.rdoc000066400000000000000000000444501515725514200230550ustar00rootroot00000000000000= Documentation for Internal Request Feature The internal request feature allows interacting with Rodauth by calling methods, and is expected to be used mostly for administrative purposes. It allows for things like an changing a login or password for an existing user, without requiring that the user login to the system. The reason the feature is named +internal_request+ is that it internally submits requests to Rodauth, which are handled almost identically to how actual web requests will be handled by Rodauth. The general form of calling these methods is: App.rodauth.internal_request_method(hash) Where +App+ is the Roda class, and +internal_request_method+ is the method you are calling. For example: App.rodauth.change_password(account_id: 1, password: 'foobar') Will change the password for the account with id 1 to +foobar+. All internal request methods support the following options. For internal requests that require an existing account, you should generally use one of the two following options: :account_id :: The id of the account to be considered as logged in when the internal request is submitted (most internal requests require a logged in account). This value is assumed to represent an existing account, the database is not checked to confirm that. :account_login :: The login of the account to be considered as logged in when the internal request is submitted (most internal requests require a login). This will query the database to determine the account's id before submitting the request. If there is no non-closed account for the login, this will raise an exception. There are additional options available, that you should only use if you have special requirements: :authenticated_by :: The array of strings to use for how the internal request's session was authenticated. :env :: A hash to merge into the internal request environment hash. Keys given will override default values, so you will probably have problems if you directly use an existing request environment. :session :: A hash for the session to use. :params :: A hash of custom parameters. All remaining options are considered parameters. Using the previous example: App.rodauth.change_password(account_id: 1, password: 'foobar') The password: 'foobar' part means that the parameters for the request will be {rodauth.password_param => 'foobar'}, where +rodauth.password_param+ is the value of +password_param+ in your Rodauth configuration (this defaults to "password"). Passing any options not mentioned above that are not valid Rodauth parameters will result in a warning. == Configuration In general, the configuration for internal requests is almost the same as for regular requests. There are some minor changes for easier usability. +modifications_require_password?+ (and similar methods for requiring password), +require_login_confirmation?+, and +require_password_confirmation?+ are set to false. In general, the caller of the method should not be able to determine the user's password, and there is no point in requiring parameter confirmation when calling the method directly. You can override the configuration for internal requests by using the +internal_request_configuration+ configuration method. For example, you can set the minimum length for logins to be 15 for normal requests, but only 3 for internal requests: plugin :rodauth do enable :create_account, :internal_request login_minimum_length 15 internal_request_configuration do login_minimum_length 3 end end Another approach for doing this is to call the +internal_request?+ method inside configuration method blocks: plugin :rodauth do enable :create_account, :internal_request login_minimum_length{internal_request? ? 3 : 15} end == Return Values and Exceptions Internal request methods ending in a question mark return true or false. Most other internal request methods return nil on success, and or raise a Rodauth::InternalRequestError exception on failure. The exception message will include the flash message, {the reason for the failure}[rdoc-ref:doc/error_reasons.rdoc] if available, and any field errors. This data can also be retrieved via +flash+, +reason+, and +field_errors+ attributes on the exception object. If an internal request method returns a non-nil value on success, it will be documented in the Features section below. In such cases, unless documented below, the methods will still raise a Rodauth::InternalRequestError exception on failure. == Domain While it is a good idea to use the +domain+ configuration method to force a domain to use, as it can avoid DNS rebinding attacks, Rodauth can function without it, as it can use the domain of the request. However, for internal requests, there is no submitted domain, and Rodauth does not know what to use as the domain. To avoid potentially using a wrong domain, Rodauth will raise an Rodauth::InternalRequestError in internal requests if a domain is needed and has not been configured. == Features This section documents the methods that are available for each feature. You must load that feature and the internal request feature in order to call the internal request methods for that feature. Some features support multiple internal request methods, and each internal request method supported will be documented under the appropriate subheading. If the method subheading states it it requires an account, you must pass the +:account_id+ or +account_login+ option when calling the method. If the method subheading states it it requires an account or a login, you must pass either +:login+, +:account_id+, or +account_login+ when calling the method. === Base === account_exists? The +account_exists?+ method returns whether the account exists for the given login. Options: +:login+ :: (required) The login for the account. === account_id_for_login The +account_id_for_login+ method returns the account id for the given login. A Rodauth::InternalRequestError is raised if the login given is not valid. Options: +:login+ :: (required) The login for the account. === internal_request_eval The +internal_request_eval+ requires a block and will +instance_eval+ the block the context of an internal request instance. This allows you full usage of the +Rodauth::Auth+ API inside the request. Before using this method, you should have a good understanding of Rodauth's internals and the effects of calling any methods you are calling inside the block. The return value of the method will be the return value of the block, unless one of the methods in the block has set a different return value. === Change Login ==== change_login (requires account) The +change_login+ method changes the login for the account. Options: +:login+ :: (required) The new login for the account. Note that if the +:account_login+ option is provided, that is the current login for the account, not the new login. === Change Password ==== change_password (requires account) The +change_password+ method changes the password for the account. Options: +:password+ or +new_password+ :: (required) The new password for the account. === Close Account ==== close_account (requires account) The +close_account+ method closes the account. There is no method in Rodauth to reopen closed accounts. === Create Account ==== create_account The +create_account+ method creates an account. Options: +:login+ :: (required) The login for the created account. +:password+ :: The password for the created account. === Email Auth ==== email_auth_request (requires account or login) The +email_auth_request+ method requests an email with an authentication link be sent to the account's email address. ==== email_auth The +email_auth+ method determines if the given email authentication key is valid. This method will return the account id if the authentication key is valid. Options: +:email_auth_key+ :: (required) The email authentication key for the account. ==== valid_email_auth? The +valid_email_auth?+ method returns whether the given email authentication key is valid. Options: +:email_auth_key+ :: (required) The email authentication key for the account. === Lockout ==== lock_account (requires account) The +lock_account+ method locks an account, even if the account has not experienced any login failures. This is one method only available as an internal request. ==== unlock_account_request (requires account or login) The +unlock_account_request+ method requests an email with an link to unlock the account be sent to the account's email address. ==== unlock_account The +unlock_account+ method unlocks the account. If an +:account_id+ or +:account_login+ option is provided, this will unlock the account without requiring the unlock account key value. Options: +:unlock_account_key+ :: The unlock account key for the account. This allows unlocking accounts by key, without knowing the account id or login. === Login ==== login (requires account or login) The +login+ method determines if the given password is valid for the given account. This method will return the account id if the password is valid. Options: +:password+ :: (required) The password for the account. ==== valid_login_and_password? (requires account or login) The +valid_login_and_password?+ method returns whether the given password is valid for the given account. Options: +:password+ :: (required) The password for the account. === OTP ==== otp_setup_params (requires account) The +otp_setup_params+ method returns a hash with an +:otp_setup+ key, and an +:otp_setup_raw+ key if the Rodauth configuration uses +hmac_secret+. The +:otp_setup+ key in the returned hash specifies the OTP secret. This hash should be merged into the options submitted to the +otp_setup+ method in order to complete OTP setup. ==== otp_setup (requires account) The +otp_setup+ method enables OTP multifactor authentication for the account. The values in the hash returned by the +otp_setup_params+ hash must be passed as options to this method. Additional Options: +:otp_auth+ :: (required) The current OTP authentication code for the OTP secret. ==== otp_auth (requires account) The +otp_auth+ method determines if the OTP authentication code is valid for the account. Options: +:otp_auth+ :: (required) The current OTP authentication code for account. ==== valid_otp_auth? (requires account) The +valid_otp_auth?+ method returns whether the OTP authentication code is valid for the account. Options: +:otp_auth+ :: (required) The current OTP authentication code for account. ==== otp_disable (requires account) The +otp_disable+ method disables OTP authentication for the account. === Recovery Codes ==== recovery_codes (requires account) The +recovery_codes+ method returns an array of recovery codes for the account. This array can be empty if no recovery codes are setup. Options: +:add_recovery_codes+ :: Generate new recovery codes for the account, up to the configured +recovery_codes_limit+, before returning the codes. ==== recovery_auth (requires account) The +recovery_auth+ method determines if the recovery authentication code is valid for the account. Options: +:recovery_codes+ :: (required) A valid recovery code for the account. This option sounds like it would take an array of recover codes, but it only takes a single recovery code. ==== valid_recovery_auth? (requires account) The +valid_recovery_auth?+ method returns whether the recovery authentication code is valid for the account. Options: +:recovery_codes+ :: (required) A valid recovery code for the account. This option sounds like it would take an array of recover codes, but it only takes a single recovery code. === Remember ==== remember_setup (requires_account) The +remember_setup+ method setups up the remember feature for the account, and returns the cookie value that can be used for the remember cookie. ==== remember_disable (requires_account) The +remember_disable+ method disables the remember feature for the account. ==== account_id_for_remember_key The +account_id_for_remember_key+ method returns the account id for the given remember key. Options: +:remember+ :: (required) The remember key for the account. This is the same value returned by +remember_setup+. === Reset Password ==== reset_password_request (requires account or login) The +reset_password_request+ method requests an email with an link to reset the password for the account be sent to the account's email address. ==== reset_password The +reset_password+ method resets the password for an account. This is similar to the +change_password+ method, but requires that a reset password key has been created for the account, and removes the key after the password has been reset. If an +:account_id+ or +:account_login+ option is provided, this will reset the password for the account without requiring the reset password key value. Options: +:password+ :: (required) The new password for the account. +:reset_password_key+ :: The reset password key for the account. This allows resetting passwords by key, without knowing the account id or login. === SMS Codes ==== sms_setup (requires account) The +sms_setup+ method sends an SMS message to the given phone number with a code to setup SMS authentication for the account. Options: +:sms_phone+ :: (required) The phone number to use to setup SMS authentication. ==== sms_confirm (requires account) The +sms_confirm+ method sets up SMS authentication for an account, confirming that the SMS authentication code sent previously was received. Options: +:sms_code+ :: (required) The authentication code sent to the user for setting up SMS authentication. ==== sms_request (requires account) The +sms_setup+ method sends an SMS message to the account's SMS phone number with an authentication code for two factor authentication. ==== sms_auth (requires account) The +sms_auth+ method determines if the SMS authentication code is valid for the account. Options: +:sms_code+ :: (required) The authentication code sent to the user via SMS. ==== valid_sms_auth? (requires account) The +valid_sms_auth?+ method returns whether the SMS authentication code is valid for the account. Options: +:sms_code+ :: (required) The authentication code sent to the user via SMS. ==== sms_disable (requires account) The +sms_disable+ method disables SMS authentication for the account. === Two Factor Base ==== two_factor_disable (requires_account) The +two_factor_disable+ method disables all multifactor authentication for the account. === Verify Account ==== verify_account_resend (requires account or login) The +verify_account_resend+ method resends the account verification email to the account's email address. ==== verify_account The +verify_account+ method verifies the account. to the account's email address. If an +:account_id+ or +:account_login+ option is provided, this will verify the account without requiring the verify account key value. Options: +:password+ :: The password for the account, if setting up passwords during verification. +:verify_account_key+ :: The verify account key for the account. This allows verifying accounts by key, without knowing the account id or login. === Verify Login Change ==== verify_login_change The +verify_login_change+ method verifies the login change for the account. If an +:account_id+ or +:account_login+ option is provided, this will verify the account without requiring the verify account key value. If the +:account_login+ option is provided, it specifies the current account login, before the change. Options: +:verify_login_change_key+ :: The verify login change key for the account. This allows verifying login changes by key, without knowing the account id or login. === WebAuthn ==== webauthn_setup_params (requires account) The +webauthn_setup_params+ method returns a hash with +:webauthn_setup+, +:webauthn_setup_challenge+ and +:webauthn_setup_challenge_hmac+ keys. The +:webauthn_setup+ options should be provided to the client for WebAuthn registration, while +:webauthn_setup_challenge+ and +webauthn_setup_challenge_hmac+ should be passed to the +webauthn_setup+ method. ==== webauthn_setup (requires account) The +webauthn_setup+ method creates a WebAuthn credential for the account. Options: +:webauthn_setup+ :: The WebAuthn credential provided by the client during registration. +:webauthn_setup_challenge+ :: The WebAuthn challenge generated for registration. +:webauthn_setup_challenge_hmac+ :: The HMAC of the WebAuthn challenge generated for registration. ==== webauthn_auth_params (requires account) The +webauthn_auth_params+ method returns a hash with +:webauthn_auth+, +:webauthn_auth_challenge+ and +:webauthn_auth_challenge_hmac+ keys. The +:webauthn_auth+ options should be provided to the client for WebAuthn authentication, while +:webauthn_auth_challenge+ and +webauthn_auth_challenge_hmac+ should be passed to the +webauthn_auth+ method. ==== webauthn_auth (requires account) The +webauthn_auth+ method determines if the given WebAuthn credential is valid for the account. Options: +:webauthn_auth+ :: The WebAuthn credential provided by the client during authentication. +:webauthn_auth_challenge+ :: The WebAuthn challenge generated for authentication. +:webauthn_auth_challenge_hmac+ :: The HMAC of the WebAuthn challenge generated for authentication. ==== webauthn_remove (requires account) The +webauthn_remove+ methods deletes the given WebAuthn credential for the account. Options: +:webauthn_remove+ :: The ID of the WebAuthn credential to delete. === WebAuthn Login ==== webauthn_login_params (requires account or login) The +webauthn_login_params+ method returns a hash with +:webauthn_auth+, +:webauthn_auth_challenge+ and +:webauthn_auth_challenge_hmac+ keys. The +:webauthn_auth+ options should be provided to the client for WebAuthn authentication, while +:webauthn_auth_challenge+ and +webauthn_auth_challenge_hmac+ should be passed to the +webauthn_login+ method. ==== webauthn_login (requires account or login) The +webauthn_login+ method determines if the given WebAuthn credential is valid for the given account. This method will return the account id if the WebAuthn credential is valid. Options: +:webauthn_auth+ :: The WebAuthn credential provided by the client during authentication. +:webauthn_auth_challenge+ :: The WebAuthn challenge generated for authentication. +:webauthn_auth_challenge_hmac+ :: The HMAC of the WebAuthn challenge generated for authentication. === WebAuthn Autofill Enabling this feature modifies +webauthn_login_params+ and +webauthn_login+ methods not to require an account or login. jeremyevans-rodauth-b53f402/doc/json.rdoc000066400000000000000000000074101515725514200204350ustar00rootroot00000000000000= Documentation for JSON Feature The json feature adds support for JSON API access for all other features that ship with Rodauth. When this feature is used, all other features become accessible via a JSON API. The JSON API uses the POST method for all requests, using the same parameter names as the features uses. JSON API requests to Rodauth endpoints that use a method other than POST will result in a 405 Method Not Allowed response. Responses are returned as JSON hashes. In case of an error, the +error+ entry is set to an error message, and the field-error entry is set to an array containing the field name and the error message for that field. Successful requests by default store a +success+ entry with a success message, though that can be disabled. The JSON response can be modified at any point by modifying the +json_response+ hash. The following example adds an {error reason}[rdoc-ref:doc/error_reasons.rdoc] to the JSON response: set_error_reason do |reason| json_response[:error_reason] = reason end The session state is managed in the rack session (just like in HTML mode), with CSRF protection being disabled by default for JSON requests. HTML mode is still available when json: true option is passed to the rodauth plugin. If you want to only handle JSON requests, set only_json? true in your rodauth configuration. If you want token-based authentication sent via the Authorization header, consider using the jwt feature. == Auth Value Methods json_accept_regexp :: The regexp to use to check the Accept header for JSON if +json_check_accept?+ is true. json_check_accept? :: Whether to check the Accept header to see if the client supports JSON responses, true by default. json_non_post_error_message :: The error message to use when a JSON non-POST request is sent. json_not_accepted_error_message :: The error message to display if +json_check_accept?+ is true and the Accept header is present but does not match +json_request_content_type_regexp+. json_request_content_type_regexp :: The regexp to use to recognize a request as a json request. json_response_content_type :: The content type to set for json responses, application/json by default. json_response_custom_error_status? :: Whether to use custom error statuses, instead of always using +json_response_error_status+, true by default, can be set to false for backwards compatibility with Rodauth 1. json_response_error? :: Whether the current JSON response indicates an error. By default, returns whether +json_response_error_key+ is set. json_response_error_key :: The JSON result key containing an error message, +error+ by default. json_response_error_status :: The HTTP status code to use for JSON error responses if not using custom error statuses, 400 by default. json_response_field_error_key :: The JSON result key containing an field error message, field-error by default. json_response_success_key :: The JSON result key containing a success message for successful request, if set. +success+ by default. non_json_request_error_message :: The error message to use when a non-JSON request is sent and +only_json?+ is set. only_json? :: Whether to have Rodauth only allow JSON requests. True by default if json: :only option was given when loading the plugin. If set, rodauth endpoints will issue an error for non-JSON requests. use_json? :: Whether to return a JSON response. By default, a JSON response is returned if +only_json?+ is true, or if the request uses a json content type. == Auth Methods json_request? :: Whether the current request is a JSON request, looks at the Content-Type request header by default. json_response_body(hash) :: The body to use for JSON response. By default just converts hash to JSON. Can be used to reformat JSON output in arbitrary ways. jeremyevans-rodauth-b53f402/doc/jwt.rdoc000066400000000000000000000067111515725514200202730ustar00rootroot00000000000000= Documentation for JWT Feature The jwt feature adds support for JSON API access for all other features that ship with Rodauth, using JWT (JSON Web Tokens) to hold the session information. It depends on the json feature. In order to use this feature, you have to set the +jwt_secret+ configuration option with the secret used to cryptographically protect the token. To use this JSON API, when processing responses for requests to a Rodauth endpoint, check for the Authorization header, and use the value of the response Authorization header as the request Authorization header in future requests, if the response Authorization header is set. If the response Authorization header is not set, then continue to use the previous Authorization header. When using this feature, consider using the json: :only option when loading the rodauth plugin, if you want Rodauth to only handle JSON requests. If you don't use the json: :only option, the jwt feature will probably result in an error if a request to a Rodauth endpoint comes in with a Content-Type that isn't application/json, unless you also set only_json? false in your rodauth configuration. If you would like to check if a valid JWT was submitted with the current request in your Roda app, you can call the +rodauth.valid_jwt?+ method. If +rodauth.valid_jwt?+ returns true, the contents of the jwt can be retrieved from +rodauth.session+. Logging the session out does not invalidate the previous JWT token by default. If you would like this behavior, you can use the active_sessions feature, which stores session identifiers in the database and deletes them when the session expires. This provides a whitelist approach of revoking JWT tokens. == Auth Value Methods invalid_jwt_format_error_message :: The error message to use when a JWT with an invalid format is submitted in the Authorization header. jwt_algorithm :: The JWT algorithm to use, +HS256+ by default. jwt_authorization_ignore :: A regexp matched against the Authorization header, which skips JWT processing if it matches. By default, HTTP Basic and Digest authentication are ignored. jwt_authorization_remove :: A regexp to remove from the Authorization header before processing the JWT. By default, a Bearer prefix is removed. jwt_decode_opts :: An optional hash to pass to +JWT.decode+. Can be used to set JWT verifiers. jwt_old_secret :: The previous JWT secret used, to support JWT secret rotation (only supported when using jwt 2.4+). Access to this should be protected the same as a session secret. jwt_secret :: The JWT secret to use. Access to this should be protected the same as a session secret. jwt_session_key :: A key to nest the session hash under in the JWT payload. nil by default, for no nesting. jwt_symbolize_deeply? :: Whether to symbolize the session hash deeply. false by default. use_jwt? :: Whether to use the JWT in the Authorization header for authentication information. If false, falls back to using the rack session. By default, the Authorization header is used if it is present, if +only_json?+ is true, or if the request uses a json content type. == Auth Methods jwt_session_hash :: The session hash used to create the session_jwt. Can be used to set JWT claims. jwt_token :: Retrieve the JWT token from the request, by default taking it from the Authorization header. session_jwt :: An encoded JWT for the current session. set_jwt_token(token) :: Set the JWT token in the response, by default storing it in the Authorization header. jeremyevans-rodauth-b53f402/doc/jwt_cors.rdoc000066400000000000000000000034661515725514200213250ustar00rootroot00000000000000= Documentation for JWT CORS Feature The jwt_cors feature adds support for Cross-Origin Resource Sharing to Rodauth's JSON API. When this feature is used, CORS requests are handled. This includes CORS preflight requests, which are required since Rodauth's JSON API uses the application/json request content type. This feature depends on the jwt feature. == Auth Value Methods jwt_cors_allow_headers :: For allowed CORS-preflight requests, the value returned in the Access-Control-Allow-Headers header (default: 'Content-Type, Authorization, Accept'). This specifies which headers can be included in CORS requests. jwt_cors_allow_methods :: For allowed CORS-preflight requests, the value returned in the Access-Control-Allow-Methods header (default: 'POST'). This specifies which methods are allowed in CORS requests. jwt_cors_allow_origin :: Which origins are allowed to perform CORS requests. The default is +false+. This can be a String, Array of Strings, Regexp, or +true+ to allow CORS requests from any domain. jwt_cors_expose_headers :: For allowed CORS requests, the value returned in the Access-Control-Expose-Headers header (default: 'Authorization'). This specifies which headers the browser is allowed to access from a response to a CORS request. jwt_cors_max_age :: For allowed CORS-preflight requests, the value returned in the Access-Control-Max-Age header (default: 86400). This specifies how long before the information returned should be considered stale and another CORS preflight request made. == Auth Methods jwt_cors_allow? :: Whether the request should be allowed. This is called for all requests for a Rodauth route that include an Origin header. It should return true or false for whether to specially handle the cross-origin request. By default, uses the +jwt_cors_allow_origin+ setting to check the origin. jeremyevans-rodauth-b53f402/doc/jwt_refresh.rdoc000066400000000000000000000122731515725514200220110ustar00rootroot00000000000000= Documentation for JWT Refresh Feature The jwt_refresh feature adds support for a database-backed JWT refresh token, setting a short lifetime on JWT access tokens. When this feature is used, the access and refresh token are provided at login in the response body (the access token is still provided in the Authorization header), and for any subsequent POST to /jwt-refresh. Note that using the refresh token invalidates the token and creates a new access token with an updated lifetime. However, it does not invalidate older access tokens. Older access tokens remain valid until they expire. You can use the active_sessions feature if you want previous access tokens to be invalid as soon as the refresh token is used. You can have multiple active refresh tokens active at a time, since each browser session will generally use a separate refresh token. If you would like to revoke a refresh token when logging out, provide the refresh token when submitting the JSON request to logout. If you would like to remove all refresh tokens for the account when logging out, provide a value of all as the token value. When using the refresh token, you must provide a valid access token, as that contains information about the current session, which is used to create the new access token. If you change the +allow_refresh_with_expired_jwt_access_token?+ setting to +true+, an expired but otherwise valid access token will be accepted, and Rodauth will check that the access token was issued in the same session as the refresh token. When an account change is made made during a logged in session (such as a login change), refresh tokens are not automatically invalidated, as Rodauth does not know which refresh token is being used for the current session. It is recommended that you use the active_sessions feature if you would like an account change during a logged in session to invalidate refresh tokens. Technically, this invalides the account tokens for other sessions and not the refresh tokens, but you need a valid access token to use the refresh token, so it has the same effect. This feature depends on the jwt feature. == Auth Value Methods allow_refresh_with_expired_jwt_access_token? :: Whether refreshing should be allowed with an expired access token. Default is +false+. You must set an +hmac_secret+ if setting this value to +true+. expired_jwt_access_token_status :: The HTTP status code to use when a access token (JWT) is expired is submitted in the Authorization header. Default is 400 for backwards compatibility, and it is recommended to set it to 401. expired_jwt_access_token_message :: The error message to use when a access token (JWT) is expired is submitted in the Authorization header. jwt_access_token_key :: Name of the key in the response json holding the access token. Default is +access_token+. jwt_access_token_not_before_period :: How many seconds before the current time will the jwt be considered valid (to account for inaccurate clocks). Default is 5. jwt_access_token_period :: Validity of an access token in seconds, default is 1800 (30 minutes). jwt_refresh_route :: The route to the login action. Defaults to jwt-refresh. jwt_refresh_invalid_token_message :: Error message when the provided refresh token is non existent, invalid or expired. jwt_refresh_token_account_id_column :: The column name in the +jwt_refresh_token_table+ storing the account id, should be a foreign key referencing the accounts table. jwt_refresh_token_data_session_key :: The key in the session hash storing random data, for access checking during refresh if +allow_refresh_with_expired_jwt_access_token?+ is set. jwt_refresh_token_deadline_column :: The column name in the +jwt_refresh_token_table+ storing the deadline after which the refresh token will no longer be valid. jwt_refresh_token_deadline_interval :: Validity of a refresh token. Default is 14 days. jwt_refresh_token_hmac_session_key :: The key in the session hash storing the hmac, for access checking during refresh if +allow_refresh_with_expired_jwt_access_token?+ is set. jwt_refresh_token_id_column :: The column name in the refresh token keys table storing the id of each token (the primary key of the table). jwt_refresh_token_key :: Name of the key in the response json holding the refresh token. Default is +refresh_token+. jwt_refresh_token_key_column :: The column name in the +jwt_refresh_token_table+ holding the refresh token key value. jwt_refresh_token_key_param :: Name of parameter in which the refresh token is provided when requesting a new token. Default is +refresh_token+. jwt_refresh_token_table :: Name of the table holding refresh token keys. jwt_refresh_without_access_token_message :: Error message when trying to refresh with providing an access token. jwt_refresh_without_access_token_status :: The HTTP status code to use when trying to refresh without providing an access token. == Auth Methods account_from_refresh_token(token) :: Returns the account hash for the given refresh token. after_refresh_token :: Hooks for specific processing once the refresh token has been set. before_jwt_refresh_route :: Run arbitrary code before handling a jwt_refresh route. before_refresh_token :: Hooks for specific processing before the refresh token is computed. jeremyevans-rodauth-b53f402/doc/lockout.rdoc000066400000000000000000000153041515725514200211450ustar00rootroot00000000000000= Documentation for Lockout Feature The lockout feature implements bruteforce protection for accounts. It depends on the login feature. If a user fails to login due to a password error more than a given number of times, their account gets locked out, and they are given an option to request an account unlock via an email sent to them. == Auth Value Methods account_lockouts_deadline_column :: The deadline column in the +account_lockouts_table+, containing the timestamp until which the account is locked out. account_lockouts_deadline_interval :: The amount of time for which to lock out accounts, 1 day by default. Only used if +set_deadline_values?+ is true. account_lockouts_email_last_sent_column :: The email last sent column in the +account_lockouts_table+. Set to nil to always send an unlock account email when requested. account_lockouts_id_column :: The id column in the +account_lockouts_table+, should be a foreign key referencing the accounts table. account_lockouts_key_column :: The unlock key column in the +account_lockouts_table+. account_lockouts_table :: The table containing account lockout information. account_login_failures_id_column :: The id column in the +account_login_failures_table+, should be a foreign key referencing the accounts table. account_login_failures_number_column :: The column in the +account_login_failures_table+ containing the number of login failures for the account. account_login_failures_table :: The table containing number of login failures per account. login_lockout_error_flash :: The flash error to show if there if the account is or becomes locked out after a login attempt. max_invalid_logins :: The maximum number of failed logins before account lockout. As this feature is just designed for bruteforce protection, this defaults to 100. no_matching_unlock_account_key_error_flash :: The flash error message to show if attempting to access the unlock account form with an invalid key. unlock_account_additional_form_tags :: HTML fragment with additional form tags to use on the unlock account form. unlock_account_autologin? :: Whether to autologin users after successful account unlock. This defaults to true, as otherwise an attacker can prevent an account from logging in by continually locking out their account. unlock_account_button :: The text to use on the unlock account button. unlock_account_email_recently_sent_error_flash :: The flash error to show if not sending an unlock account email because another was sent recently. unlock_account_email_recently_sent_redirect :: Where to redirect after not sending an unlock account email because another was sent recently. unlock_account_email_subject :: The subject to use for the unlock account email. unlock_account_error_flash :: The flash error to display upon unsuccessful account unlock. unlock_account_explanatory_text :: The text to display above the button to unlock an account. unlock_account_key_param :: The parameter name to use for the unlock account key. unlock_account_notice_flash :: The flash notice to display upon successful account unlock. unlock_account_page_title :: The page title to use on the unlock account form. unlock_account_redirect :: Where to redirect after successful account unlock. unlock_account_request_additional_form_tags :: HTML fragment with additional form tags to use on the form to request an account unlock. unlock_account_request_button :: The text to use on the unlock account request button. unlock_account_request_explanatory_text :: The text to display above the button to request an account unlock. unlock_account_request_notice_flash :: The flash notice to display upon successful sending of the unlock account email. unlock_account_request_page_title :: The page title to use on the unlock account request form. unlock_account_request_redirect :: Where to redirect after the account unlock email is sent. unlock_account_request_route :: The route to the unlock account request action. Defaults to +unlock-account-request+. unlock_account_requires_password? :: Whether a password is required when unlocking accounts, false by default. May want to set to true if not allowing password resets. unlock_account_route :: The route to the unlock account action. Defaults to +unlock-account+. unlock_account_session_key :: The key in the session to hold the unlock account key temporarily. unlock_account_skip_resend_email_within :: The number of seconds before sending another unlock account email, if +account_lockouts_email_last_sent_column+ is set. == Auth Methods account_from_unlock_key(key) :: Retrieve the account using the given verify account key, or return nil if no account matches. after_account_lockout :: Run arbitrary code after an account has been locked out. after_unlock_account :: Run arbitrary code after a successful account unlock. after_unlock_account_request :: Run arbitrary code after a successful account unlock request. before_unlock_account :: Run arbitrary code before unlocking an account. before_unlock_account_request :: Run arbitrary code before sending an account unlock email. before_unlock_account_request_route :: Run arbitrary code before handling an account unlock request route. before_unlock_account_route :: Run arbitrary code before handling an unlock account route. clear_invalid_login_attempts :: Clear any stored login failures or lockouts for the current account. create_unlock_account_email :: A Mail::Message for the account unlock email to send. generate_unlock_account_key :: A random string to use for a new unlock account key. get_unlock_account_email_last_sent :: Get the last time an unlock account email is sent, or nil if there is no last sent time. get_unlock_account_key :: Retrieve the unlock account key for the current account. invalid_login_attempted :: Record an invalid login attempt, incrementing the number of login failures, and possibly locking out the account. locked_out? :: Whether the current account is locked out. send_unlock_account_email :: Send the account unlock email. set_unlock_account_email_last_sent :: Set the last time an unlock_account email is sent. unlock_account :: Unlock the account. unlock_account_email_body :: The body to use for the unlock account email. unlock_account_email_link :: The link to the unlock account form to include in the unlock account email. unlock_account_key :: The unlock account key for the current account. unlock_account_request_response :: Return a response after successfully requesting an account unlock. By default, redirects to +unlock_account_request_redirect+. unlock_account_request_view :: The HTML to use for the unlock account request form. unlock_account_response :: Return a response after successfully unlocking an account. By default, redirects to +unlock_account_redirect+. unlock_account_view :: The HTML to use for the unlock account form. jeremyevans-rodauth-b53f402/doc/login.rdoc000066400000000000000000000064201515725514200205740ustar00rootroot00000000000000= Documentation for Login Feature The login feature implements a login page. It's the most commonly used feature. In addition to the auth methods below, it provides a +login+ method that wraps +login_session+, running login hooks and redirecting to the configured location. rodauth.account #=> { id: 123, ... } rodauth.login('password') # login the current account == Auth Value Methods login_additional_form_tags :: HTML fragment containing additional form tags to use on the login form. login_button :: The text to use for the login button. login_error_flash :: The flash error to show for an unsuccessful login. login_error_status :: The response status to use when using an invalid login or password to login, 401 by default. login_form_footer_links :: An array of entries for links to show on the login page. Each entry is an array of three elements, sort order (integer), link href, and link text. login_form_footer_links_heading :: A heading to show before the login form footer links. login_notice_flash :: The flash notice to show after successful login. login_page_title :: The page title to use on the login form. login_redirect :: Where to redirect after a successful login. login_redirect_session_key :: The key in the session hash storing the location to redirect to after successful login. login_return_to_requested_location? :: Whether to redirect to the originally requested location after successful login when +require_login+ was used, false by default. login_return_to_requested_location_max_path_size :: The maximum path size in bytes to allow when returning to requested location, 2048 by default to avoid exceeding the 4K cookie size limit login_route :: The route to the login action. Defaults to +login+. multi_phase_login_forms :: An array of entries for authentication methods that can be used to login when using multi phase login. Each entry is an array of three elements, sort order (integer), HTML, and method to call if this entry is the only authentication method available (or nil to not call a method). multi_phase_login_page_title :: The page title to use on the login form after login has been entered when using multi phase login. need_password_notice_flash :: The flash notice to show during multi phase login after the login has been entered, when requesting the password. use_multi_phase_login? :: Whether to ask for login first, and only ask for password after asking for the login, false by default unless an alternative login feature such as email_auth or webauthn_login is used. == Auth Methods before_login_route :: Run arbitrary code before handling a login route. login_form_footer :: A message to display after the login form. login_response :: Return a response after a successful login. By default, redirects to +login_redirect+ (or the requested location if +login_return_to_requested_location?+ is true). login_return_to_requested_location_path :: If +login_return_to_requested_location?+ is true, the path to use as the requested location. By default, uses the full path of the request for GET requests, and is nil for non-GET requests (in which case the default +login_redirect+ will be used). login_view :: The HTML to use for the login form. multi_phase_login_view :: The HTML to use for the login form after login has been entered when using multi phase login. jeremyevans-rodauth-b53f402/doc/login_password_requirements_base.rdoc000066400000000000000000000102021515725514200263040ustar00rootroot00000000000000= Documentation for Login Password Requirements Base Feature The login password requirements base feature is automatically loaded when you use a Rodauth feature that requires setting logins or passwords. == Auth Value Methods already_an_account_with_this_login_message :: The error message to display when there already exists an account with the same login. contains_null_byte_message :: The error message to display when the password contains a null byte (only used if parameters with null bytes are otherwise allowed). login_confirm_label :: The label to use for login confirmations. login_confirm_param :: The parameter name to use for login confirmations. login_does_not_meet_requirements_message :: The error message to display when the login does not meet the requirements you have set. login_email_regexp :: The regular expression used to validate whether login is a valid email address. login_maximum_bytes :: The maximum length for logins in bytes, 255 by default. login_maximum_length :: The maximum length for logins in characters, 255 by default. login_minimum_length :: The minimum length for logins in characters, 3 by default. login_not_valid_email_message :: The error message to display when login is not a valid email address. login_too_long_message :: The error message fragment to show if the login is too long. login_too_many_bytes_message :: The error message fragment to show if the login has too many bytes. login_too_short_message :: The error message fragment to show if the login is too short. logins_do_not_match_message :: The error message to display when login and login confirmation do not match. password_confirm_label :: The label to use for password confirmations. password_confirm_param :: The parameter name to use for password confirmations. password_does_not_meet_requirements_message :: The error message to display when the password does not meet the requirements you have set. password_hash_cost :: The cost to use for the password hash algorithm. This should be an integer when using bcrypt (the default), and a hash if using argon2 (supported by the argon2 feature). password_maximum_bytes :: The maximum length for passwords in bytes, nil by default for no limit. bcrypt only uses the first 72 bytes of the password when creating the password hash, so if you are using bcrypt as the password hash function, you may want to set this to 72. password_maximum_length :: The maximum length for passwords in characters, nil by default for no limit. password_minimum_length :: The minimum length for passwords in characters, 6 by default. password_too_long_message :: The error message fragment to show if the password is too long. password_too_many_bytes_message :: The error message fragment to show if the password is has too many bytes. password_too_short_message :: The error message fragment to show if the password is too short. passwords_do_not_match_message :: The error message to display when password and password confirmation do not match. require_email_address_logins? :: Whether logins need to be valid email addresses, true by default. require_login_confirmation? :: Whether login confirmations are required when changing logins or creating accounts. True by default if not verifying the account. require_password_confirmation? :: Whether password confirmations are required when changing/resetting passwords and creating accounts. same_as_existing_password_message :: The error message to display when a new password is the same as the existing password. == Auth Methods login_confirmation_matches?(login, login_confirmation) :: Whether the login matches the login confirmation, does a case insensitive check using +casecmp+ by default. login_meets_requirements?(login) :: Whether the given login meets the requirements. By default, just checks that the login is a valid email address. login_valid_email?(login) :: Whether the login is a valid email address. password_hash(password) :: A hash of the given password. password_meets_requirements?(password) :: Whether the given password meets the requirements. Can be used to implement complexity requirements for passwords. set_password(password) :: Set the password for the current account to the given password. jeremyevans-rodauth-b53f402/doc/logout.rdoc000066400000000000000000000017261515725514200210010ustar00rootroot00000000000000= Documentation for Logout Feature The logout feature implements a logout button, which clears the session. It is the simplest feature. == Auth Value Methods logout_additional_form_tags :: HTML fragment containing additional form tags to use on the logout form. logout_button :: The text to use for the logout button. logout_notice_flash :: The flash notice to show after logout. logout_page_title :: The page title to use on the logout form. logout_redirect :: Where to redirect after a logout. logout_route :: The route to the logout action. Defaults to +logout+. == Auth Methods after_logout :: Run arbitrary code after logout. before_logout :: Run arbitrary code before logout. before_logout_route :: Run arbitrary code before handling a logout route. logout :: Log the user out, by default clearing the session. logout_response :: Return a response after a successful logout. By default, redirects to +logout_redirect+. logout_view :: The HTML to use for the logout form. jeremyevans-rodauth-b53f402/doc/otp.rdoc000066400000000000000000000177371515725514200203030ustar00rootroot00000000000000= Documentation for OTP Feature The otp feature implements multifactor authentication via time-based one-time passwords (TOTP). It supports setting up TOTP authentication, logging in with TOTP authentication codes, and disabling TOTP authentication. The otp feature requires the rotp and rqrcode gems. == Auth Value Methods otp_already_setup_error_flash :: The flash error to show if going to the OTP setup page when OTP is already setup. otp_already_setup_redirect :: Where to redirect if going to the OTP setup page when OTP has already been setup. otp_auth_additional_form_tags :: HTML fragment containing additional form tags to use on the OTP authentication form. otp_auth_button :: Text to use for button on OTP authentication form. otp_auth_error_flash :: The flash error to show if unable to authenticate via OTP. otp_auth_failures_limit :: The number of allowed OTP authentication failures before locking out. otp_auth_form_footer :: A footer to display at the bottom of the OTP authentication form. otp_auth_label :: The label for the OTP authentication code. otp_auth_link_text :: The text to use for the link from the multifactor auth page. otp_auth_page_title :: The page title to use on the OTP authentication form. otp_auth_param :: The parameter name for the OTP authentication code. otp_auth_route :: The route to the OTP authentication action. Defaults to +otp-auth+. otp_class :: The class to use for OTP authentication (default: ROTP::TOTP) otp_digits :: The number of digits to use in OTP authentication codes (rotp's default is 6). otp_disable_additional_form_tags :: HTML fragment containing additional form tags to use on the form to disable OTP authentication. otp_disable_button :: The text to use for button on the form to disable OTP authentication. otp_disable_error_flash :: The flash error to show if unable to disable OTP authentication. otp_disable_link_text :: The text to use for the disable link from the multifactor manage page. otp_disable_notice_flash :: The flash notice to show after disabling OTP authentication. otp_disable_page_title :: The page title to use on the OTP disable form. otp_disable_redirect :: Where to redirect after disabling OTP authentication. otp_disable_route :: The route to the OTP disable action. Defaults to +otp-disable+. otp_drift :: The number of seconds the client and server are allowed to drift apart. The default is 30. Can be set to nil to not allow drift. otp_interval :: The number of seconds in which to rotate TOTP auth codes (rotp's default is 30). otp_invalid_auth_code_message :: The error message to show when an invalid OTP authentication code is used. otp_invalid_secret_message :: The error message to show when an invalid OTP secret is submitted during OTP setup. otp_issuer :: The issuer to use in the OTP provisioning URL. Defaults to +domain+. otp_keys_column :: The column in the +otp_keys_table+ containing the OTP secret. otp_keys_failures_column :: The column in the +otp_keys_table+ containing the number of OTP authentication failures. otp_keys_id_column :: The column in the +otp_keys_table+ containing the account id. otp_keys_last_use_column :: The column in +otp_keys_table+ containing the last authentication timestamp. otp_keys_table :: The table name containing the OTP secrets. otp_keys_use_hmac? :: Whether to use HMACs for OTP keys. Defaults to whether +hmac_secret+ has been set. Should be set to false if adding +hmac_secret+ to Rodauth where the otp feature is already in use, as otherwise it will render existing OTP keys invalid. otp_lockout_error_flash :: The flash error show show when OTP authentication has been locked out due to numerous authentication failures. otp_lockout_redirect :: Where to redirect if going to OTP authentication page and OTP authentication has been locked out. otp_provisioning_uri_label :: The label used when displaying the OTP provisioning URI during OTP setup. otp_secret_label :: The label used when displaying the OTP secret during OTP setup. otp_setup_additional_form_tags :: HTML fragment containing additional form tags when setting up OTP authentication. otp_setup_button :: Text for the button when setting up OTP authentication. otp_setup_error_flash :: The flash error to show if OTP authentication setup was not successful. otp_setup_link_text :: The text to use for the setup link from the multifactor manage page. otp_setup_notice_flash :: The flash notice to show if OTP authentication setup was successful. otp_setup_page_title :: The page title to use on the form to setup OTP authentication. otp_setup_param :: The parameter name used for the OTP secret when setting up OTP authentication. otp_setup_raw_param :: The parameter name used for the raw OTP secret when setting up OTP authentication, when +otp_keys_use_hmac?+ is true. otp_setup_redirect :: Where to redirect after successful OTP authentication setup. otp_setup_route :: The route to the OTP setup action. Defaults to +otp-setup+. == Auth Methods after_otp_authentication_failure :: Run arbitrary code after OTP authentication failure. after_otp_disable :: Run arbitrary code after OTP authentication has been disabled. after_otp_setup :: Run arbitrary code after OTP authentication has been setup. before_otp_auth_route :: Run arbitrary code before handling an OTP authentication route. before_otp_authentication :: Run arbitrary code before OTP authentication. before_otp_disable :: Run arbitrary code before OTP authentication disabling. before_otp_disable_route :: Run arbitrary code before handling an OTP authentication disable route. before_otp_setup :: Run arbitrary code before OTP authentication setup. before_otp_setup_route :: Run arbitrary code before handling an OTP authentication setup route. otp :: The object used for verifying OTP authentication attempts. otp_add_key(secret) :: Add an OTP key for the current account with the given secret. otp_auth_view :: The HTML to use for the OTP authentication form. otp_available? :: Whether OTP authentication is ready for use. otp_disable_response :: Return a response after successfully disabling OTP . By default, redirects to +otp_disable_redirect+. otp_disable_view :: The HTML to use for the OTP disable form. otp_exists? :: Whether the current account has setup OTP. otp_key :: The stored OTP secret for the account. otp_last_use :: The last time OTP authentication was successful for the account. otp_locked_out? :: Whether the current account has been locked out of OTP authentication. otp_new_secret :: A new secret to use when setting up OTP. otp_provisioning_name :: The provisioning name to use during OTP setup, defaults to the account's email. otp_provisioning_uri :: The provisioning URI displayed during OTP setup. otp_qr_code :: The QR code containing the otp_provisioning_uri, by default an SVG image. otp_record_authentication_failure :: Record an OTP authentication failure. otp_remove :: Removes all stored OTP data for the current account. otp_remove_auth_failures :: Removes OTP authentication failures for the current account, used after successful multifactor authentication. otp_setup_response :: Return a response after successful OTP setup. By default, redirects to +otp_setup_redirect+. otp_setup_view :: The HTML to use for the form to setup OTP authentication. otp_tmp_key(secret) :: Set the secret to use for the temporary OTP key, during OTP setup. otp_update_last_use :: Update the last time OTP authentication was successful for the account. Return true if the authentication should be allowed, or false if it should not be allowed because the last authentication was too recent and indicates the possible reuse of a TOTP authentication code. otp_valid_code_for_old_secret :: Called when valid OTP authentication is performed using hmac_old_secret. This indicates the OTP needs to be rotated before support for the previous hmac secret value is removed. You can use this to track users who need their OTP rotated, and take appropriate action. otp_valid_code?(auth_code) :: Whether the given code is the currently valid OTP auth code for the account. otp_valid_key?(secret) :: Whether the given secret is a valid OTP secret. jeremyevans-rodauth-b53f402/doc/otp_lockout_email.rdoc000066400000000000000000000041501515725514200231730ustar00rootroot00000000000000= Documentation for OTP Lockout Email Feature The otp_lockout_email feature emails users when: * TOTP authentication is locked out * TOTP authentication is unlocked * A TOTP unlock attempt has failed The otp_unlock_email feature depends on the otp_lockout and email_base features. == Auth Value Methods otp_locked_out_email_body :: Body to use for the email notifying user that TOTP authentication has been locked out. otp_locked_out_email_subject :: Subject to use for the email notifying user that TOTP authentication has been locked out. otp_unlock_failed_email_body :: Body to use for the email notifying user that there has been an unsuccessful attempt to unlock TOTP authentication. otp_unlock_failed_email_subject :: Subject to use for the email notifying user that there has been an unsuccessful attempt to unlock TOTP authentication. otp_unlocked_email_body :: Body to use for the email notifying user that TOTP authentication has been unlocked. otp_unlocked_email_subject :: Subject to use for the email notifying user that TOTP authentication has been unlocked. send_otp_locked_out_email? :: Whether to send an email when TOTP authentication is locked out. send_otp_unlock_failed_email? :: Whether to send an email when there has been an unsuccessful attempt to unlock TOTP authentication. send_otp_unlocked_email? :: Whether to send an email when TOTP authentication is unlocked. == Auth Methods create_otp_locked_out_email :: A Mail::Message for the email notifying user that TOTP authentication has been locked out. create_otp_unlock_failed_email :: A Mail::Message for the email notifying user that there has been an unsuccessful attempt to unlock TOTP authentication. create_otp_unlocked_email :: A Mail::Message for the email notifying user that TOTP authentication has been unlocked. send_otp_locked_out_email :: Send the email notifying user that TOTP authentication has been locked out. send_otp_unlock_failed_email :: Send the email notifying user that there has been an unsuccessful attempt to unlock TOTP authentication. send_otp_unlocked_email :: Send the email notifying user that TOTP authentication has been unlocked. jeremyevans-rodauth-b53f402/doc/otp_modify_email.rdoc000066400000000000000000000021451515725514200230040ustar00rootroot00000000000000= Documentation for OTP Modify Email Feature The otp_modify_email feature emails users when TOTP authentication is setup or disabled. The otp_modify_email feature depends on the otp and email_base features. == Auth Value Methods otp_disabled_email_body :: Body to use for the email notifying user that TOTP authentication has been disabled. otp_disabled_email_subject :: Subject to use for the email notifying user that TOTP authentication has been disabled. otp_setup_email_body :: Body to use for the email notifying user that TOTP authentication has been setup. otp_setup_email_subject :: Subject to use for the email notifying user that TOTP authentication has been setup. == Auth Methods create_otp_disabled_email :: A Mail::Message for the email notifying user that TOTP authentication has been disabled. create_otp_setup_email :: A Mail::Message for the email notifying user that TOTP authentication has been setup. send_otp_disabled_email :: Send the email notifying user that TOTP authentication has been disabled. send_otp_setup_email :: Send the email notifying user that TOTP authentication has been setup. jeremyevans-rodauth-b53f402/doc/otp_unlock.rdoc000066400000000000000000000147631515725514200216520ustar00rootroot00000000000000= Documentation for OTP Unlock Feature The otp_unlock feature implements unlocking of TOTP authentication after TOTP authentication. The user must consecutively successfully authenticate with TOTP multiple times (default: 3) within a given time period (15 minutes per attempt) in order to unlock TOTP authentication. By requiring consecutive successful unlocks, with a delay after failure, it is infeasible to brute force the TOTP unlock process. The otp_unlock feature depends on the otp feature. == Auth Value Methods otp_unlock_additional_form_tags :: HTML fragment containing additional form tags to use on the OTP unlock form. otp_unlock_auth_deadline_passed_error_flash :: The flash error to show if attempting to unlock OTP after the deadline for submittal has passed. otp_unlock_auth_deadline_passed_error_status :: The response status to use if attempting to unlock OTP after the deadline for submittal has passed, 403 by default. otp_unlock_auth_failure_cooldown_seconds :: The number of seconds the user must wait to attempt OTP unlock again after a failed OTP unlock attempt. otp_unlock_auth_failure_error_flash :: The flash error to show if attempting to unlock OTP using an incorrect authentication code. otp_unlock_auth_failure_error_status :: The response status to use if attempting to unlock OTP using an incorrect authentication code, 403 by default. otp_unlock_auth_not_yet_available_error_flash :: The flash error to show if attempting to unlock OTP when doing so is not yet available due to a recent attempt. otp_unlock_auth_not_yet_available_error_status :: The response status to use if attempting to unlock OTP when doing so is not yet available due to a recent attempt, 403 by default. otp_unlock_auth_success_notice_flash :: The flash notice to show upon successful unlock authentication, when additional unlock authentication is still needed. otp_unlock_auths_required :: The number of consecutive successful authentication attempts needed to unlock OTP authentication, 3 by default. otp_unlock_button :: Text to use for button on OTP unlock form. otp_unlock_consecutive_successes_label :: Text to show next to the number of consecutive successful authentication attempts the user has already made. otp_unlock_deadline_seconds :: The number of seconds between a previously successful authentication attempt and the next successful authentication attempt. This defaults to twice the amount of time of the OTP interval (30 seconds) plus twice the amount of allowed drift (30 seconds), for a total of 120 seconds. This is to make sure the same OTP code cannot be used more than one when unlocking. otp_unlock_form_footer :: A footer to display at the bottom of the OTP unlock form. otp_unlock_id_column :: The column in the +otp_unlock_table+ containing the account id. otp_unlock_next_auth_attempt_after_column :: The column in the +otp_unlock_table+ containing a timestamp for when the user can next try an authentication attempt. otp_unlock_next_auth_attempt_label :: Text to show next to the time when the next unlock authentication attempt will be allowed. otp_unlock_next_auth_attempt_refresh_label :: Text to show explaining that the page will refresh when the next unlock authentication attempt will be allowed. otp_unlock_next_auth_deadline_label :: Text to show next to the deadline for unlock authentication. otp_unlock_not_available_page_title :: The page title to use on the page letting users know they need to wait to unlock OTP authentication. otp_unlock_not_locked_out_error_flash :: The flash error to show if attempting to access the OTP unlock page when OTP authentication is not locked out. otp_unlock_not_locked_out_error_status :: The response status to use if attempting to access the OTP unlock page when OTP authentication is not locked out, 403 by default. otp_unlock_not_locked_out_redirect :: Where to redirect if attempting to access the OTP unlock page when OTP authentication is not locked out. otp_unlock_num_successes_column :: The column in the +otp_unlock_table+ containing the number of consecutive successful authentications. otp_unlock_page_title :: The page title to use on the OTP unlock form. otp_unlock_refresh_tag :: The meta refresh tag HTML to use to force a refresh of the page. This can be overridden to use a different refresh approach, such as a manual refresh. This is no longer used by default. otp_unlock_required_consecutive_successes_label :: Text to show next to the number of consecutive successful authentication attempts the user is required to make to unlock OTP authentication. otp_unlock_route :: The route to the OTP unlock action. Defaults to +otp-unlock+. otp_unlock_table :: The table name containing the OTP unlock information. otp_unlocked_notice_flash :: The flash notice to show when OTP authentication is successfully fully unlocked. otp_unlocked_redirect :: Where to redirect when OTP authentication is successfully fully unlocked. == Auth Methods after_otp_unlock_auth_failure :: Run arbitrary code after OTP unlock authentication failure. after_otp_unlock_auth_success :: Run arbitrary code after OTP unlock authentication success. after_otp_unlock_not_yet_available :: Run arbitrary code when attempting OTP unlock when it is not yet available. before_otp_unlock_attempt :: Run arbitrary code before checking whether OTP unlock authentication code is valid. before_otp_unlock_route :: Run arbitrary code before handling an OTP unlock route. otp_unlock_auth_failure :: Handle a authentication failure when trying to unlock. By default, this sets the number of consecutive successful authentication attempts to 0, and forces a significant delay before the next unlock authentication attempt can be made. otp_unlock_auth_success :: Handle a authentication failure when trying to unlock. By default, this increments the number of consecutive successful authentication attempts, and imposes a short delay before the next unlock authentication attempt can be made (to ensure the code cannot be reused). otp_unlock_available? :: Returns whether it is possible to unlock OTP authentication. This assumes that OTP is already locked out. otp_unlock_deadline_passed? :: Returns whether the deadline to submit an OTP unlock authentication code has passed. otp_unlock_not_available_set_refresh_header :: Set the Refresh response header for the otp unlock not available page, so it refreshes after an unlock attempt is available. otp_unlock_not_available_view :: The HTML to use for the page when the OTP unlock form is not yet available due to a recent unlock authentication attempt. otp_unlock_view :: The HTML to use for the OTP unlock form. jeremyevans-rodauth-b53f402/doc/password_complexity.rdoc000066400000000000000000000050621515725514200236040ustar00rootroot00000000000000= Documentation for Password Complexity Feature The password complexity feature implements more sophisticated password complexity checks. It is not recommended to use this feature unless you have a policy that requires it, as users that would not choose a good password in the absence of password complexity requirements are unlikely to choose a good password if you have password complexity requirements. Checks: * Contains characters in multiple character groups, by default at least 3 of uppercase letters, lowercase letters, numbers, and everything else, unless the password is over 11 characters. * Does not contain any invalid patterns, by default patterns like +qwerty+, +azerty+, +asdf+, +zxcv+, or number sequences such as +123+. * Does not contain a certain number of repeating characters, by default 3. * Is not a dictionary word, after stripping off numbers from the prefix and suffix and replacing some common numbers/symbols often substituted for letters, catching things like P@$$w0rd1. == Auth Value Methods password_character_groups :: An array of regular expressions representing different character groups. password_dictionary :: A Array/Hash/Set containing dictionary words, which cannot match the password. password_dictionary_file :: A file containing dictionary words, which will not be allowed. By default, /usr/share/dict/words if present. Set to false to not use a password dictionary. Note that this is only used during initialization, and cannot refer to request-specific state, unlike most other settings. password_in_dictionary_message :: The error message fragment to show if the password is derived from a word in a dictionary. password_invalid_pattern :: A regexp where any match is considered an invalid password. For multiple sequences, use +Regexp.union+. password_invalid_pattern_message :: The error message fragment to show if the password matches the invalid pattern. password_max_length_for_groups_check :: The number of characters above which to skip the checks for character groups. password_max_repeating_characters :: The maximum number of repeating characters allowed. password_min_groups :: The minimum number of character groups the password has to contain if it is less than +password_max_length_for_groups_check+ characters. password_not_enough_character_groups_message :: The error message fragment to show if the password does not contain characters from enough character groups. password_too_many_repeating_characters_message :: The error message fragment to show if the password contains too many repeating characters. jeremyevans-rodauth-b53f402/doc/password_expiration.rdoc000066400000000000000000000046531515725514200235760ustar00rootroot00000000000000= Documentation for Password Expiration Feature The password expiration feature requires that users change their password on login if it has expired (default: every 90 days). You can force password expiration checks for all logged in users by adding the following code to your route block: rodauth.require_current_password Additionally, you can set a minimum amount of time after a password is changed until it can be changed again. By default this is not enabled, but it can be enabled by setting +allow_password_change_after+ to a positive number of seconds. It is not recommended to use this feature unless you have a policy that requires it, as password expiration in general results in users choosing weaker passwords. When asked to change their password, many users choose a password that is based on their previous password, so forcing password expiration is in general a net loss from a security perspective. == Auth Value Methods allow_password_change_after :: How long in seconds after the last password change until another password change is allowed (always allowed by default). password_change_needed_redirect :: Where to redirect if a password needs to be changed. password_changed_at_session_key :: The key in the session storing the timestamp the password was changed at. password_expiration_changed_at_column :: The column in the +password_expiration_table+ containing the timestamp password_expiration_default :: If the last password change time for an account cannot be determined, whether to consider the account expired, false by default. password_expiration_error_flash :: The flash error to display when the account's password has expired and needs to be changed. password_expiration_id_column :: The column in the +password_expiration_table+ containing the account's id. password_expiration_table :: The table holding the password last changed timestamps. password_not_changeable_yet_error_flash :: The flash error to display when not enough time has elapsed since the last password change and an attempt is made to change the password. password_not_changeable_yet_redirect :: Where to redirect if the password cannot be changed yet. require_password_change_after :: How long in seconds until a password change is required (90 days by default). == Auth Methods password_expired? :: Whether the password has expired for the related account. update_password_changed_at :: Update the password last changed timestamp for the current account. jeremyevans-rodauth-b53f402/doc/password_grace_period.rdoc000066400000000000000000000020651515725514200240320ustar00rootroot00000000000000= Documentation for Password Grace Period Feature The password grace period feature keeps track of the last time the user entered their password in the session, and doesn't require they reenter their password for account modifications if they recently entered it correctly. If you would like to provide extra security before certain routes, you can use the confirm password feature to require users to reenter their password if they haven't entered it recently: rodauth.require_password_authentication By default, this does not redirect if the session has been authenticated via password, but with the password_grace_period feature, it also redirects if the password has not been entered recently. == Auth Value Methods last_password_entry_session_key :: The session key in which to store the last password entry time. password_grace_period :: The number of seconds after a password entry until password reentry is required, 300 by default (5 minutes). == Auth Methods password_recently_entered? :: Whether the password has last been entered within the grace period. jeremyevans-rodauth-b53f402/doc/password_pepper.rdoc000066400000000000000000000047731515725514200227120ustar00rootroot00000000000000= Documentation for Password Pepper Feature The password pepper feature appends a specified secret string to passwords before they are hashed. This way, if the password hashes get compromised, an attacker cannot use them to crack the passwords without also knowing the pepper. In the configuration block set the +password_pepper+ with your secret string. It's recommended for the password pepper to be at last 32 characters long and randomly generated. password_pepper "" If your database already contains password hashes that were created without a password pepper, these will get automatically updated with a password pepper next time the user successfully enters their password. If you're using bcrypt (default), you should set +password_maximum_bytes+ so that password + pepper don't exceed 72 bytes. This is because bcrypt truncates passwords longer than 72 bytes, enabling an attacker to crack the pepper if the password bytesize is unlimited. If you're using argon2, you should probably set +argon2_secret+ instead of using this feature. == Pepper Rotation You can rotate the password pepper as well, just make sure to add the previous pepper to the +previous_password_peppers+ array. Password hashes using the old pepper will get automatically updated on the next successful password match. password_pepper "new pepper" previous_password_peppers ["old pepper", ""] The empty string above ensures password hashes without pepper are handled as well. Note that each entry in +previous_password_peppers+ will multiply the amount of possible password checks during login, at least for incorrect passwords. Additionally, when using this feature with the disallow_password_reuse feature, the number of passwords checked when changing or resetting a password will be (previous_password_peppers.length + 1) * previous_passwords_to_check So if you have 2 entries in +previous_password_peppers+, using the default value of 6 for +previous_passwords_to_check+, every time a password is changed, there will be 18 password checks done, which will be quite slow. == Auth Value Methods password_pepper :: The secret string appended to passwords before they are hashed. previous_password_peppers :: An array of password peppers that will be tried on an unsuccessful password match. Defaults to [""], which allows introducing this feature with existing passwords. password_pepper_update? :: Whether to update password hashes that use a pepper from +previous_password_peppers+ with a new pepper. Defaults to +true+. jeremyevans-rodauth-b53f402/doc/path_class_methods.rdoc000066400000000000000000000007411515725514200233300ustar00rootroot00000000000000= Documentation for Path Class Methods Feature The path class methods feature allows for calling the *_path and *_url methods directly on the class, as opposed to an instance of the class. In order for the *_url methods to be used, you must use the base_url configuration so that determining the base URL doesn't depend on the submitted request, as the request will not be set when using the class method. Failure to do this will probably result in a NoMethodError being raised. jeremyevans-rodauth-b53f402/doc/recovery_codes.rdoc000066400000000000000000000115541515725514200225030ustar00rootroot00000000000000= Documentation for Recovery Codes Feature The recovery codes feature allows multifactor authentication via single use recovery codes. It is usually used as a backup if other multifactor authentication methods are not available or have been locked out, but can be used by itself. It allows users to view authentication recovery codes as well as regenerate recovery codes. Access to recovery codes is limited to authenticated sessions only, so users should be recommended to securely store/preserve a subset of these codes prior to any chance of them being required due to a missing / lost device. == Auth Value Methods add_recovery_codes_redirect :: Where to redirect to add recovery codes if recovery codes are the primary multifactor authentication and have not been setup yet. add_recovery_codes_button :: Text to use for button on the form to add recovery codes. add_recovery_codes_error_flash :: The flash error to show when adding recovery codes. add_recovery_codes_heading :: Text to use for heading above the form to add recovery codes. add_recovery_codes_page_title :: The page title to use on the add recovery codes form. add_recovery_codes_param :: The parameter name to use for adding recovery codes. auto_add_recovery_codes? :: Whether to automatically add recovery codes (or any missing recovery codes) when enabling otp, webauthn, or sms authentication (false by default). auto_remove_recovery_codes? :: Whether to automatically remove recovery codes when disabling otp, webauthn, or sms authentication and not having one of the other two authentication methods enabled (false by default). invalid_recovery_code_error_flash :: The flash error to show when an invalid recovery code is used. invalid_recovery_code_message :: The error message to show when an invalid recovery code is used. recovery_auth_additional_form_tags :: HTML fragment containing additional form tags when authenticating via a recovery code. recovery_auth_button :: The text to use for the button when authenticating via a recovery code. recovery_auth_link_text :: The text to use for the link from the multifactor auth page. recovery_auth_page_title :: The page title to use on the form to authenticate via a recovery code. recovery_auth_redirect :: Where to redirect after authenticating via an recovery code. recovery_auth_route :: The route to the recovery code authentication action. Defaults to +recovery-auth+. recovery_codes_added_notice_flash :: The flash notice to show when recovery codes were added. recovery_codes_additional_form_tags :: HTML fragment containing additional form tags when adding recovery codes. recovery_codes_column :: The column in the +recovery_codes_table+ containing the recovery code. recovery_codes_id_column :: The column in the +recovery_codes_table+ containing the account id. recovery_codes_label :: The label for recovery codes. recovery_codes_limit :: The number of recovery codes to setup. recovery_codes_link_text :: The text to use for the setup link from the multifactor manage page. recovery_codes_page_title :: The page title to use on the form to view recovery codes. recovery_codes_param :: The parameter name for the recovery code. recovery_codes_primary? :: Whether recovery codes are a primary multifactor authentication type. If not, they cannot be setup unless multifactor authentication is already setup. recovery_codes_route :: The route to the view recovery codes action. Defaults to +recovery-codes+. recovery_codes_table :: The table storing the recovery codes. view_recovery_codes_button :: Text for the button to view recovery codes. view_recovery_codes_error_flash :: The flash error to show when viewing recovery codes was not successful. == Auth Methods add_recovery_code :: Add a recovery code for the given account. add_recovery_codes_view :: The HTML to use for the add recovery codes form. after_add_recovery_codes :: Run arbitrary code after adding recovery codes. before_add_recovery_codes :: Run arbitrary code before adding recovery codes. before_recovery_auth :: Run arbitrary code before recovery code authentication. before_recovery_auth_route :: Run arbitrary code before handling recovery code authentication route. before_recovery_codes_route :: Run arbitrary code before handling view/add recovery codes route. before_view_recovery_codes :: Run arbitrary code before viewing recovery codes. can_add_recovery_codes? :: Whether the current account can add more recovery codes. new_recovery_code :: A new recovery code to insert into the recovery codes table. recovery_auth_view :: The HTML to use for the form to authenticate via a recovery code. recovery_code_match?(code) :: Whether the given code matches any of the existing recovery_codes. recovery_codes :: An array containing all valid recovery codes for the current account. recovery_codes_available? :: Whether authentication via recovery codes is ready for use. recovery_codes_view :: The HTML to use for the form to view recovery codes. jeremyevans-rodauth-b53f402/doc/release_notes/000077500000000000000000000000001515725514200214415ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/doc/release_notes/1.0.0.txt000066400000000000000000000446241515725514200226500ustar00rootroot00000000000000= Highlights * Two factor authentication support via TOTP, SMS, and recovery codes * Support for any database supported by Sequel * Full security support on PostgreSQL, MySQL, and MSSQL * Full support for all features via JSON APIs, using JWT tokens * Support for common IT security policies: * Password complexity checks * Disallowing reuse of recent passwords * Password expiration * Account expiration * Session expiration * Limiting accounts to a single session = Backwards Compatibility * Rodauth now defaults to skipping status checks on accounts unless the verify account or close account features are used. Previously, skip_status_checks? was false by default regardless of which features were in use. * Rodauth no longer uses Sequel::Models for accounts, all database access is done through Sequel datasets. Users should switch to using the db, accounts_table, and account_select configuration methods if needed. The account_model configuration method still exists for backwards compatibility, but it just warns and calls those methods. * The account_id_value configuration method has been renamed to account_id. * The account_id and account_status_id configuration methods have been renamed to account_id_column and account_status_column. This is more consistent with other features, which use *_column for column names. * Before hooks (e.g. before_login) are executed before actions that change state. Before route hooks (e.g. before_login_route) have been added and are now called in the same place as the previous before hooks. * Rodauth now uses flash errors instead of flash notices if the message is not specifically a success message. For example, if a login is required and the user is redirected to a login page, a flash error is used instead of a flash notice. * Field errors are now stored in the rodauth object instead of instance variables in the Roda scope. This will affect you if you were doing custom overrides of Rodauth's templates and were expecting errors in instance variables. You can now retrieve a field error using something like rodauth.field_error('login'), where the argument is the related parameter name. * Rodauth now requires bcrypt by default. If you are not using bcrypt for authentication, you should set the following in your Rodauth configuration: require_bcrypt? false * Rodauth now requires mail by default if using the lockout, reset password, or verify account features. If you are using a custom mail library, you should set the following in your Rodauth configuration: require_mail? false * Rodauth now asks for the current password by default on all account modification forms (such as change password). You can disable this by setting modifications_require_password? to false. * In the lockout feature, unlock_account_autologin? is now true by default. Previously, it was false by default, which left open a persistent denial of service attack if the account could be locked out between when the account was unlocked and when the user could login again. You can now set unlock_account_requires_password? to true if you want to check for the current password when unlocking the account. However, if you are enabling password resets, this doesn't add any security as anyone controlling the email address could reset their password before unlocking the account. * Rodauth now requires that logins are valid email addresses and at least 3 or more characters by default. You can set require_email_address_logins? to false to not require email address logins, and login_minimum_length to set the minimum length for logins. You can also have custom login requirement checks by overriding login_meets_requirements?. * Changing and resetting passwords now checks that the new password is not the same as the existing password. Similarly, changing logins now checks that the new login is not the same as the existing login. * create_account_autologin? is now true by default unless using the verify_account feature, and verify_account_autologin? is now true by default. * Rodauth features are now stored under lib/rodauth/features instead of under lib/roda/plugins/rodauth. Additionally, Rodauth features should now go under the Rodauth namespace instead of the Roda::RodaPlugins::Rodauth namespace. Also, Rodauth's internal APIs have changed significantly to make it easier to create features. Anyone using external Rodauth features needs to update them to work with the new path structure, namespacing, and APIs. * The ability to override specific routes in the routing tree has been removed from Rodauth. Previously, you could use configuration methods such as login_post_route to override Rodauth's handling of POST /login. These methods no longer exist. Instead of using them, you should just override the appropriate route in your routing tree before calling r.rodauth. * Rodauth now requires securerandom on initialization. Previously, it did not require securerandom unless/until it was needed. As all rack session handlers require securerandom, and all supported ruby versions support securerandom, this should only affect you if you are using a custom session handler that does not use securerandom and your ruby implementation does not support securerandom. * Many Rodauth::Auth methods have been made private. Previously most methods were public as the internal routing blocks were evaluated in the Roda scope instead of the context of the Rodauth::Auth object. Additionally, if the feature defines a private method but you override it with a configuration method, the overridden method now remains private. * The password confirmation part of the remember feature has been split off into a separate confirm password feature with its own route, and most of the configuration method names have changed to reflect this. * The routes to request an account unlock, request a password reset, and resend the verify account email have been split into their own routes, instead of using the same route names and handling requests differently based on whether certain parameters were submitted. * Per-request route names are no longer supported due to an optimization. If you really need per-request route names, please open an issue and they can be brought back as an option. * Support for Roda < 2.6 has been dropped. = New Features * An OTP feature has been added for 2nd factor authentication via TOTP (Time-Based One-Time Password, RFC 6238). This allows TOTP setup, including displaying a QR code that can be scanned via a mobile phone, authentication via TOTP authentication codes, and disabling of TOTP authentication. * An SMS codes feature has been added for backup 2nd factor authentication via authentication codes sent in SMS messages. This supports registering a mobile phone number, confirming that you can receive authentication codes on the mobile phone number, requesting an SMS authentication code, input of the SMS authentication code, and disabling of SMS authentication. As ruby has many different SMS libraries, and robust SMS gateways generally require payments, Rodauth does not actually send the SMS messages itself, any user using the SMS codes feature needs to use the sms_send configuration method: sms_send do |phone_number, message| SomeSMSLibrary.send(phone_number, message) end * A recovery codes feature has been added for backup 2nd factor authentication via single-use account recovery codes. This supports viewing existing recovery codes, as well as generating additional recovery codes. * A JWT feature has been added, which adds JSON API support for all features that ship with Rodauth. By default, authentication data is stored in JWT tokens that are passed via the Authorization headers in the request and response. A POST-only JSON API is used, where submitted parameters should use the same names as the browser would use, all of which are configurable using Rodauth's configuration methods. By default, unsuccessful requests receive a 400 status code with a JSON object body with "error" and possibly "field-error" entries, and successful requests receive a 200 status code with an empty JSON object body. * A password complexity feature has been added for configurable password complexity checks, such as: * Contains characters in multiple character groups (default 3), unless the password is over a given length (default 11). * Does not contain common character or number sequences such as qwerty and 123. * Does not contain a certain number of repeating characters (default 3). * Does not contain a dictionary word, after stripping of numbers from the start and end of the password, and replacing common character substitutions (0 for o, $ for s). * A disallow password reuse feature has been added, which stores previous password hashes in addition to current passwords hashes, and does not allow a user to reuse a recent password (by default, any of their last 6). Previous password hashes are stored with the same security as the current password hash, so by default on PostgreSQL, MySQL, and Microsoft SQL Server, the application's database account does not have access to read them and must use database functions to retrieve the salts, compute hashes, and check if the hashes match. * A password expiration feature has been added, which requires that users change their password after a given amount of time (default is 90 days). It also supports not allowing password changes until a given amount of time after the last password change, to prevent users from quickly rotating their password back to their original password if disallowing password reuse. By default, passwords are only checked for expiration on login. If you want to check passwords on every access, you can use: rodauth.require_current_password at the appropriate point in your routing block. If a password has expired, the user will be redirected to the change password form. * An account expiration feature has been added, which disallows access to accounts after an amount of time since last login or activity. The default is to only track login times, and expire accounts based on their last login time. However, if you allow long running sessions, this may not provide an accurate picture of the last time the account was used. If you want to expire accounts based on last activity, you should set expire_account_on_last_activity? to true and use: rodauth.update_last_activity at the appropriate place in your routing block. This method is fairly expensive as it requires database access every time it is called. * A single session feature has been added, which limits each account to a single logged in session. Upon any login to an account, any previous session will no longer be valid. To make sure that this is enforced, you need to use: rodauth.check_single_session at the appropriate place in your routing block. This method is fairly expensive as it requires database access every time it is called. * A session expiration feature has been added, which can automatically expire sessions based on inactivity (default 30 minutes) and max lifetime (default 1 day) checks. To make sure that session expiration is enforced, you need to use: rodauth.check_session_expiration at the appropriate place in your routing block. * A password grace period feature has been added, which makes it so passwords are not needed for account changes if the password has been entered recently (default 5 minutes). * A verify account grace period feature has been added, which automatically logs accounts in on account creation, and allows them to login without verification for a period of time after creation (default 1 day). After the time period has expired, the account cannot log in until it has been verified. * A verify change login feature has been added, which requires that accounts that change logins reverify they have access to the new email address. This depends on the verify account grace period feature, and allows them to continue to use the account during the grace period, but after the grace period has expired, they can no longer log in until the account has been reverified. = Other Improvements * All of Rodauth's features should now work on any database that Sequel supports, and Rodauth is fully tested on PostgreSQL, MySQL, SQLite, and Microsoft SQL Server. Rodauth's full security support, which prevents the application database account from accessing password hashes, is fully tested on PostgreSQL, MySQL, and Microsoft SQL Server. * r.rodauth is now O(1) instead of O(N) where N is the number of rodauth routes. * Rodauth now uses a timing-safe algorithm for all token comparisons, avoiding possible timing attacks on tokens. * Rodauth now supports rodauth.authenticated? method for checking if the user has been authenticated. If the user has setup two factor authentication, this checks that the user has been authenticated via two factors. rodauth.require_authentication has also been added, which redirects the user to the appropriate authentication page if they have not been authenticated. * All of Rodauth's routes for modifying accounts, such as change password, now require the user be authenticated via two factors if they have setup two factor authentication. * You can now disable login/password confirmation by setting require_login_confirmation? and require_password_confirmation? to false. This is useful when using the JSON API support, where confirmation checks would generally be done client side. * Rodauth now supports a set_deadline_values? method for whether to set deadline values for tokens explicitly on a per-request basis, and *_interval configuration methods for how long to set such deadlines: set_deadline_values? true account_lockouts_deadline_interval :days=>2 remember_deadline_interval :days=>60 reset_password_deadline_interval :days=>7 In order for this feature to work, Rodauth will load Sequel's date_arithmetic extension into the Sequel::Database object it uses. Note that set_deadline_values? defaults to true on MySQL, as MySQL does not support non-constant column defaults. * Rodauth supports more specific password requirement error messages, showing which specific password requirement was not met. * A reset_password_deadline_column method has been added for overriding the column name used to store the reset password deadlines. * Many configuration methods were added to the remember feature to control the parameter names and labels used. Configuration methods were also added for flash notices and errors in the remember feature. * rodauth.load_memory in the remember feature now checks that the account is still active. Previously, the remember feature could be used to log into inactive accounts if the accounts remember token was not correctly deleted. Additionally, any invalid tokens in cookies will result in the removal of the cookie. * When extend_remember_deadline? is used, rodauth.load_memory correctly extends the deadline to be based on the current timestamp, and also updates the cookie instead of just updating the database. * The close account feature now supports a delete_account_on_close? option, which will delete accounts after closing them. * The close account feature now works correctly when skipping status checks or when using account_password_hash_column. * A password_hash_id_column has been added for specifying the account id column in the password hash table. * A token separator configuration method has been, to override the default token separator of "_". * You can now add your own methods easily to the rodauth object via auth_class_eval: plugin :rodauth do enable :login, :logout after_login do log('logged in') end after_logout do log('logged out') end auth_class_eval do def log(msg) LOGGER.info("#{account[:email]} #{msg}") end end end The auth_class_eval block is evaluated in the context of the Rodauth::Auth class that the rodauth plugin builds. Methods you define in this block are then callable on the rodauth object inside the routing tree block. * Rodauth now only allows requesting an account unlock if the account is currently locked out. * If an account is locked out during login, the appropriate error message is now displayed immediately, instead of waiting until the next request. * Rodauth now does better error handling in the lockout, reset password and verify account features. Previously, users may have received 404 errors when using invalid tokens in these features. * Rodauth now uses separate templates for shared form input fields, making it easier to override handling of individual fields without overriding entire templates. * Rodauth now supports authentication without database functions when using the recommended schema of storing password hashes in a separate table. Previously, if database functions were not used, Rodauth only supported storing password hashes in the same table as the accounts. * Creating the database authentication functions that Rodauth uses can now be done by requiring rodauth/migrations and calling the Rodauth.create_database_authentication_functions method with the appropriate Sequel::Database object. * You no longer need to call super() in before and after hooks. * Rodauth now handles race conditions related to unique constraint violations where it is possible to do so. In the cases where it is not possible to handle the race condition correctly, an exception will still be raised. * Non-integer account ids now work correctly in tokens. * Rodauth now uses frozen string literals by default on ruby 2.3 * The random_key and password_hash_cost default methods have been made faster by using conditionals to define separate methods, instead of conditionals inside the methods. * As Rodauth can now be used in JSON API only mode, the gem dependencies are limited to roda and sequel. When used outside of JSON API only mode, it also requires tilt and rack_csrf. * Rodauth.version has been added for getting the version of Rodauth in use. * Travis-CI is now used for continuous integration testing on ruby 1.8.7-2.3.0, JRuby 1.7 (1.8 and 1.9 modes), and JRuby 9.0, using PostgreSQL, MySQL, and SQLite. jeremyevans-rodauth-b53f402/doc/release_notes/1.1.0.txt000066400000000000000000000005611515725514200226410ustar00rootroot00000000000000= New Features * The rodauth plugin now supports :csrf=>false and :flash=>false options. This will make it so it no longer depends on the csrf or flash plugins, which is useful when the csrf and flash functionality is provided via a different approach, such as when rodauth is being used inside middleware in a Rails application with the roda-rails library. jeremyevans-rodauth-b53f402/doc/release_notes/1.10.0.txt000066400000000000000000000055541515725514200227300ustar00rootroot00000000000000= New Features * A verify_login_change feature has been added. This is designed as a replacement for the previous verify_change_login feature, which was problematic as it could result in a user being unable to access their account if they used an incorrect email when changing their login. The verify_login_change feature does not change the user's login until after the user has confirmed that they can receive email using the new login. The verify_login_change feature requires an additional database table to store information on login changes, so it is not a drop in replacement for the verify_change_login feature. However, it is recommended that all users of verify_change_login switch to verify_login_change. * If using the reset_password feature, there is now a link on the login page to a page that will allow you to request a password reset. Previously you had to attempt to login with the account in order to request a password reset. * If using the verify_account feature, there is now a link on the login page to a page that will allow you to request that the account verification email be resent. Previously you had to attempt to login with the account or attempt to create a new account with the same login in order to get an account verification email resent. * If using the reset_password feature, there is now a login_failed_reset_password_request_form configuration method for customizing the HTML used for the request password reset form shown when there is a login failure. = Improvements * When using the verify_account_grace_period feature, attempting to create a new account using the same login as an existing unverified account now correctly offers the ability to resend the account verification email. * The precompile_rodauth_templates method now works with the reset_password feature. * When attempting to reopen a rodauth configuration in a subclass of the Roda class that created the rodauth configuration, a subclass of the rodauth configuration is now automatically created. This makes it so changes to the rodauth configuration in the Roda subclass no longer affect the rodauth configuration of the superclass. * The FeatureConfiguration instances for each feature are now assigned to constants, making inspect output more descriptive. * An internals guide has been added, which explains the metaprogramming used to implement Rodauth. = Backwards Compatibility * Any external features should start providing two arguments to Feature.define, with the second argument being the constant name to use. So instead of: module Rodauth Foo = Feature.define(:foo) do # ... end end switch to: module Rodauth Feature.define(:foo, :Foo) do # ... end end This will ensure that the related FeatureConfiguration instance is assigned to a constant. jeremyevans-rodauth-b53f402/doc/release_notes/1.11.0.txt000066400000000000000000000025321515725514200227220ustar00rootroot00000000000000= New Features * A rodauth.valid_jwt? method has been added, allowing for easy checking of whether a valid JWT has been submitted. If a valid JWT has been submitted, the contents of the JWT will be available in rodauth.session. * If using the jwt feature with json_response_custom_error_status? set to true, and going to a page that requires a login when not logged in, a 401 error status will now be used instead of a 400 error status. You can customize this status using the new login_required_error_status configuration method. = Improvements * Time differences between the database server and the application server are now handled slightly better in the password_expiration feature. This mostly affects testing, where sometimes tests would previously fail if the database server time was ahead of the application server time when testing whether a password change was allowed. * Some methods that were private by default, but public if overridden, are now public by default. These include update_session and only_json? in the base feature, and json_request?, jwt_secret, and use_jwt? in the jwt feature. = Backwards Compatibility * The private jwt_payload method in the jwt feature now returns false instead of redirecting on error. This should not affect the application unless the method was being called explicitly. jeremyevans-rodauth-b53f402/doc/release_notes/1.12.0.txt000066400000000000000000000045361515725514200227310ustar00rootroot00000000000000= Security Fix * The password reset key deadline was previously ignored when checking for a password reset key. This allowed expired keys to be used. This problem exists in all previous versions. The root cause of this issue is that support for deadline checking was not previously implemented. In previous versions, the deadline was only used to remove old keys when creating a new key. Rodauth only allows a single password reset key per account, and deletes password reset keys when passwords are reset. So if the user had subsequently generated a different password reset key, or had already used the password reset key to reset the password, then they would not be vulnerable. The most likely situation where there exists a vulnerability due to this issue is: * A user requests a password reset. * They do not reset their password or request another password reset. * The password reset key deadline expires. * An attacker gets access to their archived email containing the password reset link, which they use to reset the password for the account. Reporting Details: * Initially reported on 10/3/2017 * Fixed in repository on 10/3/2017 * Version 1.12.0 released with fix on 10/3/2017 Thanks to Chris Hanks for discovering and reporting this issue and supplying an initial fix. = New Features * The http_basic_auth feature now supports a require_http_basic_auth configuration method. When set to true, if authentication is required and the request is not already authenticated, they will get a 401 response instead of a redirect to the login page. * All of the following Rodauth migration methods now support an options hash: * Rodauth.drop_database_authentication_functions * Rodauth.create_database_previous_password_check_functions * Rodauth.drop_database_previous_password_check_functions These options allow you to customize the get_salt_name and valid_hash_name database functions, as well as set the the table for the previous_password_check_functions. * A :search_path option is now supported when using the following Rodauth migration methods on PostgreSQL: * Rodauth.create_database_authentication_functions * Rodauth.drop_database_authentication_functions This sets the search_path to use inside the function. For backwards compatibility, it defaults to 'public, pg_temp'. jeremyevans-rodauth-b53f402/doc/release_notes/1.13.0.txt000066400000000000000000000027151515725514200227270ustar00rootroot00000000000000= New Features * A cache_templates configuration method has been added, which can be set to false to disable the default caching of templates. The main time you would want to use this is if you were overriding Rodauth's templates with your own templates and modifying such templates in development mode. If that is the case, you may want to use something like: cache_templates(ENV['RACK_ENV'] != 'development') * An invalid_previous_password_message configuration method has been added to the change_password feature, which overrides the default invalid_password_message configuration method if the incorrect previous password is used when changing the password. This is designed for use when invalid_password_message has been overridden and the message doesn't make sense in the change password case. * A json_response_body(hash) configuration method has been added to the jwt feature, allowing for custom formatting of the JSON response body. This is called with the hash to use in the response, and should return a JSON-formatted string. Example: json_response_body do |hash| super('status'=>response.status, 'detail'=>hash) end = Other Improvements * In the jwt feature, if json_response_custom_error_status? is set to true, custom error statuses will be used if only_json? is set to true, even if the request is not in JSON format. Previously, custom error statuses were only used if the request was in JSON format. jeremyevans-rodauth-b53f402/doc/release_notes/1.14.0.txt000066400000000000000000000015161515725514200227260ustar00rootroot00000000000000= New Features * A change_password_notify feature has been added, which emails the user when the change_password feature is used to change their password. This can alert the user when their password may have been changed without their knowledge. = Other Improvements * When using the account_expiration feature with the reset_password feature, resetting the passwords for expired accounts is no longer allowed. Note that the previous behavior isn't considered a security issue, because even after resetting their password, expired accounts could not login. * When using the account_expiration feature with the lockout feature, unlocking expired accounts is no longer allowed. Note that the previous behavior isn't considered a security issue, because even after unlocking the account, expired accounts could not login. jeremyevans-rodauth-b53f402/doc/release_notes/1.15.0.txt000066400000000000000000000015611515725514200227270ustar00rootroot00000000000000= New Features * create_account_set_password? and verify_account_set_password? configuration methods have been added to the create_account and verify_account features. Setting: verify_account_set_password? true in your rodauth configuration will change Rodauth so that instead of asking for a password on the create account form, it will ask for a password on the verify account form. This can fix a possible issue where an attacker creates an account for a user with a password the attacker knows. If the user clicks on the link in the verify account email and clicks on the button on the verify account page, the attacker would have have a verified account that they know the password to. By setting verify_account_set_password? to true, you can ensure that only the user who has access to the email can enter the password for the account. jeremyevans-rodauth-b53f402/doc/release_notes/1.16.0.txt000066400000000000000000000022001515725514200227170ustar00rootroot00000000000000= New Features * A disallow_common_passwords feature has been added. This feature by default will disallow the 10,000 most common passwords: enable :disallow_common_passwords You can supply your own file containing common passwords separated by newlines ("\n"): most_common_passwords_file '/path/to/file' You can also supply a password dictionary directly as any object that responds to include?: most_common_passwords some_password_dictionary_object The reason only the 10,000 most common passwords are used by default is larger password files would significantly bloat the size of the gem. Also, because the most common passwords are kept in memory by default for performance reasons, larger password files can bloat the memory usage of the process (the disallow_common_passwords feature should use around 500KB of memory by default). For very large password dictionaries, consider using a custom object that does not keep all common passwords in memory. = Other Improvements * Rodauth no longer uses the Rack::Request#[] method to get parameter values. This method is deprecated in Rack 2. jeremyevans-rodauth-b53f402/doc/release_notes/1.17.0.txt000066400000000000000000000015761515725514200227370ustar00rootroot00000000000000= Improvements * Support has been added for using Roda's route_csrf plugin with request-specific CSRF tokens. When loading the Rodauth into your Roda app, specify the :csrf=>:route_csrf plugin option so that Rodauth will load the route_csrf plugin instead of the csrf plugin. * The use_request_specific_csrf_tokens? configuration option has been added, it defaults to true when the the :csrf=>:route_csrf option is used when loading the plugin. * If you have custom templates for the reset password request, unlock account request, or verify account resend link request, you will have to update them to use the new request-specific CSRF token feature. = Backwards Compatibility * The csrf_tag configuration method now accepts the path as an optional argument, previously it accepted no arguments. The optional argument defaults to the path of the current request. jeremyevans-rodauth-b53f402/doc/release_notes/1.18.0.txt000066400000000000000000000020441515725514200227270ustar00rootroot00000000000000= New Features * flash_error_key and flash_notice_key configuration methods have been added for setting the keys used in the flash hash. * A confirm_password_redirect_session_key configuration method was added for configuring the session key used for storing the confirm password redirect. = Other Improvements * Support for the new Roda sessions plugin has been added. Rodauth now recognizes the :sessions_convert_symbols Roda application option and will default to using string keys instead of symbol keys for session and flash values if the application option is set. = Backwards Compatibility * If the :sessions_convert_symbols Roda application option is used, and the jwt feature is used and the jwt_symbolize_deeply? configuration method is not used, then the session data will not have the top-level data converted to symbols. * If the Roda application defines a clear_session method in the scope, that method is now called by Rodauth to clear the session data. This is for better integration with the Roda sessions plugin. jeremyevans-rodauth-b53f402/doc/release_notes/1.19.0.txt000066400000000000000000000117461515725514200227410ustar00rootroot00000000000000= New Features * An email_auth feature has been added, which allows passwordless logins using links sent via email. This allows usage without any password storage. If the user does not have a password, when they submit their login, they are sent a link via email. If the user has a password, they have the option of either entering their password or being sent a link via email. * A use_multi_phase_login? configuration method has been added. If this configuration method is set to true, a two-phase login is used, which the login form only has a field for a user's login. After the login form has been submitted (assuming there is a valid login), a form is displayed with a field for the password. * Optional email rate limiting is now supported in the lockout, reset_password, and verify_account features, using the following configuration methods: * account_lockouts_email_last_sent_column * reset_password_email_last_sent_column * verify_account_email_last_sent_column These methods are nil by default. To enable rate limiting, set these to a symbol representing the column name in the appropriate table. The recommended column name is email_last_sent. To use this feature, you'll have to add this column to the appropriate tables: DB.add_column :account_lockouts, :email_last_sent, DateTime DB.add_column :account_password_reset_keys, :email_last_sent, DateTime, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP DB.add_column :account_verification_keys, :email_last_sent, DateTime, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP When this support is enabled, by default Rodauth will not send an email if an email has been sent within the last 5 minutes. You can change this time period using the following configuration methods, which take the number of seconds that must have elapsed before sending another email: * unlock_account_skip_resend_email_within * reset_password_skip_resend_email_within * verify_account_skip_resend_email_within The new email_auth feature also supports email rate limiting, and because there are no backwards compatibility issues, the support is enabled by default. * An after_account_lockout configuration method has been added, which is called directly after locking out an account. This can be useful for audit logging. * A default_post_email_redirect configuration has been added, which sets the default for all redirects after emailing if the account is not currently logged in. Each individual feature that emails still supports the appropriate *_redirect configuration method for specifying behavior for that feature. * A verify_login_change_duplicate_account_redirect configuration method has been added for where to redirect if a user attempts a login change where the new proposed login already exists. * before_verify_login_change_email and after_verify_login_change_email configuration methods have been added for executing code before or after the verify login change email is sent. = Other Improvements * When using the verify_login_change feature, Rodauth now checks that the new login is not already taken and fails in a more graceful manner. Previously, Rodauth would not report an error when the login change was requested, and would raise an exception when attempting to verify the login change due to the violation of a uniqueness constraint. * Rodauth now avoids unnecessary database queries when using the two factor authentication support and the following methods: * authenticated? * require_authentication * require_two_factor_setup * The otp-setup template now looks nicer when using both Bootstrap 3 and 4, especially on small screens such as phones. * If the database_type was not MySQL, the lockout, remember, and reset_password features no longer disable the requiring of the date_arithmetic Sequel extension if another feature that requires the extension is used. * On MySQL, the rodauth_get_salt database function definition now handles accounts without passwords. If you previously added the database function and want to support accounts without passwords, then you should drop the function and re-add it via: Rodauth.drop_database_authentication_functions(DB) Rodauth.create_database_authentication_functions(DB) Note that MySQL does not support CREATE OR REPLACE FUNCTION, so you have to drop the function and then create it, which will temporarily result in the function not being defined. = Backwards Compatibility * The before_otp_authentication_route configuration method is deprecated, please switch to before_otp_auth_route instead. This change is made so that before_*_route method names are consistent with the route name. * The verify_account_email_sent_redirect configuration method now defaults to / instead of /login. If you were previously not setting this configuration method and would like it to default to /login, you will now have to force the setting: verify_account_email_sent_redirect '/login' jeremyevans-rodauth-b53f402/doc/release_notes/1.2.0.txt000066400000000000000000000014061515725514200226410ustar00rootroot00000000000000= New Features * An otp_drift configuration method has been added to the otp plugin, which allows you to set the number of seconds of allowed drift. This makes the otp plugin easier to use if the client and server do not have good synchronize to the same time source. = Other Improvements * Passwords containing the ASCII NUL character "\0" are no longer allowed, as bcrypt truncates the password at the first NUL character. Note that bcrypt only uses the first 72 characters of the password when constructing the hash, but Rodauth does not enforce a limit of 72 characters. If you want to enforce a maximum password length in your application, use the password_meets_requirements? configuration method with a block and call super inside the block. jeremyevans-rodauth-b53f402/doc/release_notes/1.20.0.txt000066400000000000000000000172261515725514200227300ustar00rootroot00000000000000= New Features * An hmac_secret configuration method has been added. If set, Rodauth will use HMACs for all of the tokens that Rodauth creates. By using HMACs for the tokens, even if the database storing the tokens is compromised (e.g. via an SQL injection vulnerability), the tokens stored in the database will not be usable without knowledge of the HMAC secret. The following features are affected by setting the hmac_secret configuration method: * email_auth * lockout * otp * remember * reset_password * single_session * verify_account * verify_login_change To allow for graceful transition when adding hmac_secret to an existing Rodauth installation, you can use the allow_raw_email_token? configuration method to keep allowing raw tokens. However, you should remove the allow_raw_email_token? setting after the existing tokens have expired (most tokens expire after 1 day by default). Verify account tokens do not expire, but users can request a new verify account token if their token has expired. For remember tokens, the raw_remember_token_deadline configuration method can be used, which will only allow the use of raw remember tokens before the given deadline, which should be the time in the future when you want to no longer accept raw remember tokens. You can remove this configuration method after the deadline has passed. By default, the deadline should be set to 14 days after the time you enable hmac_secret, since remember tokens expire in 14 days by default. Similarly, in the single_session feature, you can use the allow_raw_single_session_key? configuration method to allow raw single session keys. In the otp feature, you cannot mix HMAC and non-HMAC tokens. If the hmac_secret setting is enabled and there are any existing otp tokens already setup, they will stop working. If you are already using the otp feature and would like to use the hmac_secret configuration method, you need to set the otp_keys_use_hmac? configuration method to false unless you want to invalidate all existing otp tokens. The hmac_secret configuration is also used during OTP setup in the otp feature, to ensure that the OTP secrets for two factor authentication came from the server and were not modified by the user. If hmac_secret is used, setting up OTP via JSON requires sending a POST request to the otp-setup route. This request will fail, but included in the response will be the OTP secret and raw OTP secret to use. Submitting a POST request including the OTP secret and raw OTP secret will allow OTP setup to complete. * A jwt_refresh feature has been added. This uses the jwt feature, and issuing short-lived JWTs with exp, iat, and nbf claims, with a database-backed refresh token for issuing another short-lived JWT. The refresh tokens will automatically use HMACs if the hmac_secret configuration method is set. * Rodauth's handling of form errors is now accessible by default. aria-invalid attributes are now used on all input fields with errors, and aria-describedby attributes are used to tie the input fields to the error messages. * All hard coded strings are now overridable via configuration methods, with the following configuration methods added: * lockout feature * unlock_account_explanatory_text * unlock_account_request_explanatory_text * login_password_requirements_base feature * already_an_account_with_this_login_message * otp feature * otp_provisioning_uri_label * otp_secret_label * recovery_codes feature * add_recovery_codes_heading * reset_password feature * reset_password_explanatory_text * verify_account feature * verify_account_resend_explanatory_text * The following configuration methods have been added to the base feature, related to customization of input fields in Rodauth forms: default_field_attributes :: The default attributes to use for input field tags, if field_attributes does not handle the field. field_attributes(field) :: The attributes to use for input fields with the given parameter name. field_error_attributes(field) :: The attributes to use for input fields with the given parameter name if the field has an error. formatted_field_error(field, error) :: HTML to use for the given parameter name and error text. Uses a span by default. input_field_error_class :: The CSS class to add for input fields with errors. input_field_error_message_class :: The CSS class to add for error message spans. input_field_label_suffix :: Adds suffix to all input field labels login_input_type :: The input type to use for login fields. Defaults to text, but can be set to email, though that is currently a bad idea if you want the login fields to have accessible error handling. mark_input_fields_as_required? :: Whether to mark all input fields as required by default. Note that this is currently a bad idea if you want the fields to have accessible error handling. = Other Improvements * rotp 5 is now supported in the otp feature. Previous rotp versions down to rotp 2.1.1 remain supported. * Performance of Rodauth routes has been improved by using defined methods instead of instance_exec for route dispatching. Internal unnecessary uses of instance_exec have also been removed for performance reasons. * When the disallow_password_reuse feature is used without the verify_account feature, and account_password_hash_column configuration is not used, Rodauth no longer tries to call a method that does not exist. * When using the disallow_password_reuse and verify_account features, with verify_account_set_password? set to true, Rodauth skips adding an empty password to the list of previous passwords. * Rodauth now avoids an unnecessary DELETE query in the disallow_password_reuse feature if there are no previous passwords. * The otp-auth-code field now has an autocomplete=off attribute. * On Ruby 1.8, new tokens now use URL safe base64 encoding, instead of hex encoding. Rodauth has always used URL safe base64 encoding for new tokens on Ruby 1.9+. = Backwards Compatibility * The following configuration methods have been renamed: * email_auth feature * no_matching_email_auth_key_message => no_matching_email_auth_key_error_flash * lockout feature * no_matching_unlock_account_key_message => no_matching_unlock_account_key_error_flash * reset_password feature * no_matching_reset_password_key_message => no_matching_reset_password_key_error_flash * verify_account feature * attempt_to_create_unverified_account_notice_message => attempt_to_create_unverified_account_error_flash * attempt_to_login_to_unverified_account_notice_message => attempt_to_login_to_unverified_account_error_flash * no_matching_verify_account_key_message => no_matching_verify_account_key_error_flash * verify_login_change feature * no_matching_verify_login_change_key_message => no_matching_verify_login_change_key_error_flash Attempts to use the old method at configuration time, or calling the method on the rodauth object at runtime, will result in a deprecation warning. jeremyevans-rodauth-b53f402/doc/release_notes/1.21.0.txt000066400000000000000000000010231515725514200227150ustar00rootroot00000000000000= Improvements * rotp 5.1 is now supported in the otp feature. Previous rotp versions down to rotp 2.1.1 remain supported. * When using the otp feature without the sms or recovery_codes features, if an account gets locked out from OTP authentication due to multiple invalid OTP authentication codes, automatically log them out, and redirect them to the login page. Previously, the default behavior in this case could be a redirect loop if OTP authentication is required for the user on the default_redirect page. jeremyevans-rodauth-b53f402/doc/release_notes/1.22.0.txt000066400000000000000000000006111515725514200227200ustar00rootroot00000000000000= New Features * A jwt_cors feature has been added, handling Cross-Origin Resource Sharing when using the jwt feature, including supporting CORS preflight requests. = Other Improvements * Mail templates that include links (e.g. for verifying accounts), now add a space after the link and before the newline, fixing issues with some web mail providers that have broken auto-linkers. jeremyevans-rodauth-b53f402/doc/release_notes/1.23.0.txt000066400000000000000000000026061515725514200227270ustar00rootroot00000000000000= New Features * When the email_auth feature is used, the link to request email authentication is now displayed if the user inputs an incorrect password. Previously, it was only shown if the user had not yet entered a password. * A send_email configuration method has been added, which can be overridden to customize email delivery (such as logging such email). The configuration method block accepts a Mail::Message argument. * All rodauth.*_route methods that return the name of the route segment now have rodauth.*_path and rodauth.*_url equivalents, which return the path and URL for the related routes, respectively. The rodauth.*_path methods are useful when constructing links to the related Rodauth pages on the same site, and the rodauth.*_url methods are useful for constructing link to the Rodauth pages from other sites or in email. = Other Improvements * Specs have been removed from the gem file, reducing gem size by over 20%. * rodauth.authenticated? now returns true on the OTP setup page when using the otp feature. Previously, this method returned false on the OTP setup page. However, as the user has not yet setup OTP when viewing this page, they should be considered fully authenticated, as they would be if they viewed any other page before setting up OTP. This change probably only affects cases where the layout uses rodauth.authenticated?. jeremyevans-rodauth-b53f402/doc/release_notes/1.3.0.txt000066400000000000000000000016061515725514200226440ustar00rootroot00000000000000= New Features * A login_maximum_length configuration method has been added. This defaults to 255, and rodauth will now show an error message if a user tries to create a login longer than this setting. = Backwards Compatibility * Rodauth's documentation and test code now use :Bignum instead of Bignum for database-independent 64-bit integer types. This is because using Bignum is now deprecated in Sequel as it will stop working correctly in ruby 2.4+, due to the unification of Fixnum and Bignum into Integer. Rodauth's library code does not use either :Bignum or Bignum, but if you are starting to use Rodauth and are copying the example migration from Rodauth's documentation, or you are running the migrations in Rodauth's tests, you now need to use Sequel 4.35.0+. * Some files related to the hosting of the demo site on Heroku have been removed from the repository. jeremyevans-rodauth-b53f402/doc/release_notes/1.4.0.txt000066400000000000000000000007461515725514200226510ustar00rootroot00000000000000= New Features * A update_password_hash feature has been added, which will update the password hash for the account whenever the account's current password hash has a cost different from the currently configured password hash cost. This allows you to increase the password hash cost for all accounts or for certain types of accounts, and have the password hashes automatically updated to use the new cost the next time the correct password is provided for the account. jeremyevans-rodauth-b53f402/doc/release_notes/1.5.0.txt000066400000000000000000000056121515725514200226470ustar00rootroot00000000000000= jwt Feature Additions/Improvements * JSON format responses now have the response content type set to application/json. * The jwt feature now does not break if HTTP Basic or Digest authentication is used. * If jwt_check_accept? is true, Rodauth will return a 406 error if a request Accept header is provided and it does not indicate that JSON is acceptable. * Many new configuration methods have been added: * invalid_jwt_format_error_message: The error message to use when a JWT with an invalid format is submitted in the Authorization header. * json_accept_regexp: The regexp to use to check the Accept header for JSON if jwt_check_accept? is true. * json_not_accepted_error_message: The error message to display if jwt_check_accept? is true and the Accept header is present but does not match json_request_content_type_regexp. * json_request_content_type_regexp: The regexp to use to recognize a request as a json request. * json_response_content_type: The content type to set for json responses, application/json by default. * jwt_authorization_ignore: A regexp matched against the Authorization header, which skips JWT processing if it matches. By default, HTTP Basic and Digest authentication are ignored. * jwt_authorization_remove: A regexp to remove from the Authorization header before processing the JWT. By default, a Bearer prefix is removed. * jwt_check_accept?: Whether to check the Accept header to see if the client supports JSON responses, false by default for backwards compatibility. * session_jwt: An encoded JWT for the current session. * use_jwt?: Whether to use the JWT in the Authorization header for authentication information. If false, falls back to using the rack session. By default, the Authorization header is used if it is present, if only_json? is true, or if the request uses a json content type. = jwt Feature Backwards Compatibility Issues * The only_json? setting in the jwt feature is now only true by default if the :json=>:only option was used when loading the rodauth plugin into the roda app. Previously, it was always true, but it only was considered in requests to Rodauth endpoints. It now also is considered in most Rodauth calls, and if true will use an empty session instead of falling back to the rack session if an Authorization header is not present. * Previously, the jwt feature only handled requests where the request content-type is JSON. It now also handles non-JSON requests if the Authorization header is present or if only_json? is true. * If an invalid JWT format is used in the Authorization header, Rodauth now returns a 400 error, instead of raising an exception. = Other Improvements * A template_opts configuration method has been added, for overriding the view/render options. One possible use for this is to specify a non-default layout. jeremyevans-rodauth-b53f402/doc/release_notes/1.6.0.txt000066400000000000000000000022461515725514200226500ustar00rootroot00000000000000= New Feature * An http_basic_auth feature has been added, allowing the use of HTTP Basic Auth to login. = New Configuration Options for jwt Feature * jwt_session_hash has been added, for modifying the hash given before creating the JWT. This can be used for setting JWT claims. Example: jwt_session_hash do super().merge(:exp=>Time.now.to_i + 120) end * jwt_decode_opts has been added for specifying additional options to JWT.decode. Among other things, this allows for JWT claim verification. Example: jwt_decode_opts(:verify_expiration=>true) * jwt_session_key has been added, specifying a key in the JWT that will be used to store session information, instead of storing session keys in the root of the JWT. Use of this option can avoid issues with reserved JWT claim names, and will probably be enabled by default starting in Rodauth 2. * jwt_symbolize_deeply? configuration method has been added, for whether to symbolize nested keys when decoding a JWT session hash. = Other Improvements * The reset_password feature no longer attempts to render a template in json-only mode. * The jwt_payload method is now memoized by default. jeremyevans-rodauth-b53f402/doc/release_notes/1.7.0.txt000066400000000000000000000004421515725514200226450ustar00rootroot00000000000000= Improvements * The reset password, unlock account, and verify account features now temporarily store the feature-specific keys in the session instead of keeping them as parameters, which avoids leaking the keys to asset hosts or other external servers via the HTTP Referer header. jeremyevans-rodauth-b53f402/doc/release_notes/1.8.0.txt000066400000000000000000000012261515725514200226470ustar00rootroot00000000000000= Improvements * When using a browser, Rodauth now uses an appropriate 401, 403, or 422 error status for errors instead of using 200 success status. Many configuration methods have been added to customize the status codes used for specific types of errors. * The json_response_custom_error_status? configuration method has been added to the jwt feature, which if set to true makes the jwt feature use the same error status codes for JSON API requests that it would use for browser requests. For backward compatibility, the default is to continue to use the 400 error status for all errors in the JSON API, but this will change in Rodauth 2. jeremyevans-rodauth-b53f402/doc/release_notes/1.9.0.txt000066400000000000000000000012021515725514200226420ustar00rootroot00000000000000= New Features * Roda.precompile_rodauth_templates has been added. This method allows for precompiling the templates that rodauth uses, which allows for memory saving when using a forking webserver that preloads the application, and also allows Rodauth to be used with an application that uses chroot after loading. = Improvements * If requesting a password reset link more than once, the same password reset key will be used. Previously, subsequent emails after the first request would contain an invalid key, so if the email for the original request was lost, you could not generate another key until that key expired. jeremyevans-rodauth-b53f402/doc/release_notes/2.0.0.txt000066400000000000000000000377551515725514200226600ustar00rootroot00000000000000= New Features * A webauthn feature has been added, allowing multifactor authentication using WebAuthn. It allows for registering multiple WebAuthn authenticators per account, authenticating using WebAuthn, and removing WebAuthn authenticators. This feature depends on the webauthn gem. WebAuthn in browsers requires javascript to work, but Rodauth's approach has the javascript set hidden form inputs and then use a standard form submission, making it easy to test applications using WebAuthn without a full browser, as long as a software WebAuthn authenticator can be used (the webauthn gem provides such an authenticator). * A webauthn_login feature has been added, allowing passwordless logins using WebAuthn. * A webauthn_verify_account feature has been added, which requires setting up a WebAuthn authenticator during account verification. This allows for setups where WebAuthn is the sole method of authentication. * An active_sessions feature has been added, which disallows session reuse after logout, and allows for a global logout of all sessions for the account. It also supports inactivity and lifetime deadlines for sessions. This also integrates with the jwt_refresh feature to disable JWT access token usage after logout. * An audit_logging feature has been added, which logs Rodauth actions to a database table. This hooks into all of Rodauth's after_* hooks, and will implement audit logging for all features that use such hooks. * The confirm_password feature can now operate as multifactor authentication if the user has a password but was originally authenticated using the webauthn_login feature. * The multifactor authentication support now better handles multiple multifactor authentication methods. When setting up multifactor authentication, a page is provided linking to all enabled multifactor authentication options. When authenticating via an additional factor, a page is provided linking to all multifactor authentication options that have been setup and are available for use. There is also a page to disable all multifactor authentication methods that have been setup, and revert to single factor authentication. To provide a better user experience, if there would only be a single link on the pages to setup multifactor authentication or authenticate with an additional factor, the user is redirected directly to the appropriate page. * A translate configuration method has been added. This is called with a translation key and default value for the translation, and allows for internationalizing Rodauth. All translatable strings are passed through this method, including flash messages, page titles, button text, field error messages, and link texts. * login_return_to_requested_location? and two_factor_auth_return_to_requested_location? configuration methods have been added. With these methods set to true, if rodauth.require_login needs to redirect, it will store the current page, and after logging in, the user will be redirected back to the page. Likewise, if rodauth.require_two_factor_authenticated needs to redirect, it will store the current page, and after multifactor authentication, the user will be redirected back to the page. * domain and base_url configuration methods have been added and it is recommended that applications use them if they can be reached with arbitrary Host headers. If not set, Rodauth will use information from the request, which can be provided by an attacker. * The *_url and *_path methods now accept an optional hash of query parameters to use. * Many Rodauth forms will now use appropriate autocomplete and inputmode attributes on form inputs. You can modify the behavior using the following configuration methods: * autocomplete_for_field? * inputmode_for_field? * mark_input_fields_with_autocomplete? * mark_input_fields_with_inputmode? * An sms_phone_input_type configuration method has been added and now defaults to tel. Previous, the SMS phone input used a text type. * rodauth.require_password_authentication has been added to the confirm_password_feature, which will redirect to the login page if not logged in, and will redirect to the confirm password page if the user was logged in without typing in a password. If the password_grace_period feature is used, this also redirects if the password has not been entered recently. * rodauth.authenticated_by has been added, which is an array of strings for all methods by which the current session has been authenticated, or nil if the session has not been authenticated. * rodauth.possible_authentication_methods has been added, which is an array of strings for all methods by which the current session could be authenticated. * rodauth.autologin_type now returns the type of autologin used if authenticated using autologin. * All *_view configuration methods now have *_page_title configuration methods for setting custom page titles. = Other Improvements * The templates Rodauth uses by default are now compatible with Bootstrap 4, and compatibility with Bootstrap 3 (which Rodauth previously targeted) has been improved. * When requesting a password reset, if the user provides an invalid login, an input for the login is now displayed so the problem can be corrected. * When setting up an additional multifactor authentication method, Rodauth no longer overrides which multifactor authentication method was used to authenticate the current session. * When disabling a multifactor authentication method that was not used to authenticate the current session, the session remains multifactor authenticated. * When multiple multifactor authentication methods are setup for an account, disabling a multifactor authentication method will not mark the session as not having multifactor authentication enabled. * When disabling OTP authentication, future calls to rodauth.otp_exists? will return false instead of true. * Recovery codes are no longer generated automatically when OTP or SMS authentication is setup. There is no point generating codes that the user has not yet viewed, and generating them automatically will disable automatic redirections in the cases where only one multifactor authentication method is setup. This can be turned back on using the auto_add_recovery_codes? configuration method. * The OTP setup page now displays better on phones and other devices with small viewports. * Links and alternative login forms shown on the login page are now in a specific order and not based on the order in which features were enabled. * The link to resend the verify account email is not shown on the multi-phase login page after the login has been entered if the account has already been verified. * The modifications_require_password? configuration method now defaults to false for accounts that do not have a password. * Multifactor authentication is no longer allowed using the same factor type as used for initial authentication. Previously, no multifactor authentication type could be used for initial authentication, so this wasn't an issue. * The verify login change page no longer calls already_logged_in if the session is already logged in. This method is documented to only be called on pages that expect not to be already logged in, and it's common to access the verify login change page while being logged in, since you need to be logged in to go to the change login page. The default behavior of already_logged_in is to do nothing, so this only affects you if you have used the already_logged_in configuration method. * If using the email_auth and verify_account_grace_period features together, do not show email authentication as an option for unverified accounts during the grace period. * In the lockout feature, generate the unlock account key before calling send_unlock_account_email, similar to how key generation happens in other features that send email. This makes it easier to override the method. * Various method visibility issues have been fixed, so that enabling any feature that ships with Rodauth will not affect visibility of methods for features already enabled. * All Rodauth configuration methods (over 1000) are now documented. = Backwards Compatibility * The verify_change_login feature has been removed. Users should switch to the verify_login_change feature, which verifies the new login works correctly before switching the login. * For CSRF protection, Roda's route_csrf plugin is now used by default instead of rack_csrf. This supports request specific CSRF tokens by default. The :csrf=>:rack_csrf plugin option can be used to continue using rack_csrf. Roda's route_csrf allows for per-route checking of the CSRF token, and support for that is enabled for all Rodauth routes. However, if you were using Rodauth without explicitly loading rack_csrf, these changes could remove CSRF support from your application. You should probably load Roda's route_csrf plugin explicitly and use it in your Roda routing tree if you want CSRF protection for non-Rodauth routes. You can use the new check_csrf_opts and check_csrf_block to customize options to pass to check_csrf!, or set check_csrf? false to disable calling check_csrf!. * Email rate limiting is now enabled by default in the lockout, reset_password, and verify_account features. This requires adding a column to store the last email sent time to the related tables, if the tables were created without one: DB.add_column :account_password_reset_keys, :email_last_sent, DateTime, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP DB.add_column :account_verification_keys, :email_last_sent, DateTime, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP DB.add_column :account_lockouts, :email_last_sent, DateTime Alternatively, you can set the appropriate configuration method (e.g. verify_account_email_last_sent_column) to nil to disable rate limiting. * The http_basic_auth feature has been changed significantly. You should now call rodauth.http_basic_auth in the routing tree to load authentication information from the Authorization request header, similar to how rodauth.load_memory works in the remember feature. The require_http_basic_auth configuration method has been renamed to require_http_basic_auth?. rodauth.require_http_basic_auth? should now be used to check whether HTTP basic auth is required. rodauth.require_http_basic_auth now requires that HTTP basic auth is provided in the request. To be more backwards compatible, if not already logged in, rodauth.require_login will load HTTP basic auth information if available, and will require HTTP basic auth if require_http_basic_auth? is configured. * If using the Bootstrap 3/4 compatibility, the forms used are now standard (vertical) Bootstrap forms. Previously, they were horizontal forms. * Most of the strings related to multifactor authentication have been changed to refer to multifactor authentication instead of two factor authentication, or changed to refer to a specific multifactor authentication type (such as TOTP), as appropriate. * Periods at the end of some default flash messages have been removed for consistency. * The remember feature no longer depends on the confirm_password feature. You must now enable confirm_password separately if you want to use it. * Login confirmation is no longer required by default when verifying accounts or verifying login changes. In both cases, entering an invalid login causes no problems. * The otp_drift configuration method now defaults to 30, to allow 30 seconds of drift. The previous setting of nil generally resulted in usability problems, especially without good clock synchronization. * The json_response_custom_error_status? configuration method now defaults to true, so that custom error statuses are now used by default, instead of a generic 400 response. * The jwt_check_accept? configuration method now defaults to true, so that the request Accept header is checked. * The verify_account_set_password? configuration method now defaults to true, so that passwords will be set when verifying accounts instead of when creating accounts. This prevents issues when an attacker creates an account with a password they know, if the user with access to the email address verifies the account. * The mark_input_fields_as_required? configuration method now defaults to true. Most of rodauth's input fields are required, and this provides a nicer experience. However, it may cause accessibility issues if screen readers do not handle invalid form submissions due to missing required fields in an accessible manner. * The login_input_type configuration method now defaults to email if login_column is :email (the default setting). This can cause accessibility issues if screen readers do not handle invalid form submissions due to an invalid login field format in an accessible manner. It can also break installations that leave login_column as :email but do not use email addresses for logins. * The json_response_success_key configuration method now defaults to success, so success messages are included by default. This can be set back to nil to not include them. * The single_session and session_expiration plugin now use a configurable error status code for JSON requests when the session has expired, using inactive_session_error_status and session_expiration_error_status configuration methods, respectively. * If you are using the jwt_refresh feature and used the migration previously recommended in the README, you should mark the account_id field as NOT NULL and add an index: DB.alter_table(:account_jwt_refresh_keys) do set_column_not_null :account_id add_index :account_id, :name=>:account_jwt_rk_account_id_idx end * The otp authentication form no longer shows SMS or recovery code information on failure. The multifactor authentication page will have links to SMS or recovery code authentication if they have been setup, and will redirect or show the appropriate links to those authentication methods if OTP authentication gets locked out. * Disabling OTP authentication no longer automatically disables SMS authentication and recovery codes, and disabling SMS authentication no longer disables recovery codes. To disable all multifactor authentication methods at once, the new multifactor authentication disable page should be used. If you want to revert to the previous behavior of automatic disabling, override after_otp_disable to disable SMS and recovery codes, and override after_sms_disable to disable recovery codes. * HTML id attributes in the recovery_codes and remember features have been modified to use - instead of _, for consistency with all other Rodauth features. * Ruby 1.8 support has been dropped. The minimum supported version is now Ruby 1.9.2. Support for versions of Ruby that are no longer supported by ruby-core may be dropped in future minor releases if keeping the support becomes a maintenance issue. * The following configuration methods have been replaced: * create_account_link -> create_account_link_text * reset_password_request_link -> reset_password_request_link_text * verify_account_resend_link -> verify_account_resend_link_text The new methods take only the text of the link, the path to link to can already be determined by Rodauth. * The following configuration methods have been removed: * account_model * attempt_to_create_unverified_account_notice_message * attempt_to_login_to_unverified_account_notice_message * before_otp_authentication_route * clear_remembered_session_key * no_matching_email_auth_key_message * no_matching_reset_password_key_message * no_matching_unlock_account_key_message * no_matching_verify_account_key_message * no_matching_verify_login_change_key_message * remembered_session_key * two_factor_session_key Most of these methods were already deprecated. * Route blocks in external Rodauth features must now have an arity of 1. jeremyevans-rodauth-b53f402/doc/release_notes/2.1.0.txt000066400000000000000000000025141515725514200226420ustar00rootroot00000000000000= New Features * A check_csrf configuration method has been added for checking the CSRF token. This is useful in cases where the CSRF protection is provided by something other than the Roda route_csrf plugin. = Other Improvements * When using the http_basic_auth feature, logged_in? now checks for Basic authentication if the session is not already authenticated and Basic authentication has not yet been checked. This increases compatibility for applications that were using the http_basic_auth feature in Rodauth 1. * When creating accounts, the password field now correctly uses the new-password autocomplete attribute instead of the current-password autocomplete attribute. * When using the jwt feature, Rodauth no longer checks CSRF tokens in requests to Rodauth routes if the request submitted is a JSON request, includes a JWT, or Rodauth has been configured in JSON-only mode. * When using the verify_account_grace_period feature, if there is an unverified account without a password, do not consider the account open. Attempting to login into the account in such a case now shows a message letting the user know to verify the account. * The json_response_body configuration method is now used consistently in the jwt feature for all JSON responses. Previously, there were some cases that did not use it. jeremyevans-rodauth-b53f402/doc/release_notes/2.10.0.txt000066400000000000000000000042071515725514200227230ustar00rootroot00000000000000= New Features * An argon2 feature has been added that supports using the argon2 password hashing algorithm instead of the bcrypt password hashing algorithm. While argon2 does not provide an advantage over bcrypt if the attacker cannot access the password hashes directly (which is how Rodauth is recommended to be used), in cases where attackers can access the password hashes directly, argon2 is thought to be more difficult or expensive to crack due to requiring more memory (bcrypt is not a memory-hard password hash algorithm). If you are using this feature with Rodauth's database authentication functions, you need to make sure that the database authentication functions are configured to support argon2 in addition to bcrypt. You can do this by passing the :argon2 option when calling the method to define the database functions. In this example, DB should be your Sequel::Database object (this could be self if used in a Sequel migration): require 'rodauth/migrations' # If the functions are already defined and you are not using PostgreSQL, # you need to drop the existing functions. Rodauth.drop_database_authentication_functions(DB) # If you are using the disallow_password_reuse feature, also drop the # database functions related to that if you are not using PostgreSQL: Rodauth.drop_database_previous_password_check_functions(DB) # Define new functions that support argon2: Rodauth.create_database_authentication_functions(DB, argon2: true) # If you are using the disallow_password_reuse feature, also define # new functions that support argon2 for that: Rodauth.create_database_previous_password_check_functions(DB, argon2: true) You can transparently migrate bcrypt password hashes to argon2 password hashes whenever a user successfully uses their password by using the argon2 feature in combination with the update_password_hash feature. = Other Improvements * Unnecessary queries to determine whether the new password matches a previous password are now skipped when using the create_account or verify_account features with the disallow_password_reuse feature. jeremyevans-rodauth-b53f402/doc/release_notes/2.11.0.txt000066400000000000000000000025251515725514200227250ustar00rootroot00000000000000= New Features * An :auth_class rodauth plugin option has been added, allowing a user to specify a specific Rodauth::Auth subclass to use, instead of always using a new subclass of Rodauth::Auth. This is designed for advanced configurations or other frameworks that build on top of Rodauth, which may want to customize the Rodauth::Auth subclasses to use. * Two additional configuration methods have been added for easier translatability, fixing issues where English text was hardcoded: * same_as_current_login_message (change_login feature) * contains_null_byte_message (login_password_requirements_base feature) = Other Improvements * Loading the rodauth plugin multiple times in the same application with different blocks now works better. The same context is now shared between the blocks, so you can load features in one block and call configuration methods added by the feature in the other block. Previously, you could only call configuration methods in the block that added the feature, and enabling a feature in a block that was already enabled in a previous block did not allow the use of configuration methods related to the feature. * Passing a block when loading the rodauth plugin is now optional. * The autocomplete attribute on the reset password form now uses new-password instead of current-password. jeremyevans-rodauth-b53f402/doc/release_notes/2.12.0.txt000066400000000000000000000007001515725514200227170ustar00rootroot00000000000000= New Features * The following configuration methods have been added to the active_sessions feature: * active_sessions_insert_hash * active_sessions_key * active_sessions_update_hash * update_current_session? These methods allow you to control what gets inserted and updated into the active_sessions_table, and to control whether to perform updates. = Other Improvements * A typo was fixed in the default unlock account email. jeremyevans-rodauth-b53f402/doc/release_notes/2.13.0.txt000066400000000000000000000014431515725514200227250ustar00rootroot00000000000000= New Features * A set_error_reason configuration method has been added. This method is called whenever a error occurs in Rodauth, with a symbol describing the error. The default implementation of this method does nothing, it has been added to make it easier for Rodauth users to implement custom handling for specific error types. See the Rodauth documentation for this method to see the list of symbols this method can be called with. = Other Improvements * When using active_sessions and jwt_refresh together, and allowing for expired JWTs when refreshing, you can now call rodauth.check_active_session before r.rodauth. Previously, this did not work, and you had to call rodauth.check_active_session after r.rodauth. * The default templates now also support Bootstrap 5. jeremyevans-rodauth-b53f402/doc/release_notes/2.14.0.txt000066400000000000000000000011401515725514200227200ustar00rootroot00000000000000= New Features * A remembered_session_id method has been added for getting the account id from a valid remember token, without modifying the session to log the account in. = Other Improvements * The jwt_refresh feature's support for allowing refresh with an expired access token now works even if the Rodauth configuration uses an incorrect prefix. * The internal account_in_unverified_grace_period? method now returns false if an account has not been loaded and the session has not been logged in. Previously, calling this method in such cases would result in an exception being raised. jeremyevans-rodauth-b53f402/doc/release_notes/2.15.0.txt000066400000000000000000000041641515725514200227320ustar00rootroot00000000000000= New Features * An internal_request feature has been added. This feature allows for interacting with Rodauth by calling methods, instead of having to use a website or JSON API. This feature is designed primarily for administrative use, so that administrators can create accounts, change passwords or logins for accounts, and handle similar actions without the user of the account being involved. For example, assuming you've loaded the change_password and internal_request features, and that your Roda class that is loading Rodauth is named App, you can change the password for the account with id 1 using: App.rodauth.change_password(account_id: 1, password: 'foobar') The internal request methods are implemented as class methods on the Rodauth::Auth subclass (the object returned by App.rodauth). These methods call methods on a subclass of that class specific to internal requests. The reason the feature is named internal_request is that these methods are implemented by submitting a request internally, that is processed almost exactly the same way as Rodauth would process a web request. See the internal_request feature documentation for details on which internal request methods are available and the options they take. * A path_class_methods feature has been added, that allows for calling *_path and *_url as class methods. If you would like to call the *_url methods as class methods, make sure to use the base_url configuration method to set the base URL so that it does not require request-specific information. * Rodauth::Auth classes now have a configuration_name method that returns the configuration name associated with the class. They also have a configuration method that returns the configuration associated with the class. * Rodauth::Feature now supports an internal_request_method method for specifying which methods are supported as internal request methods. = Other Improvements * The default base_url configuration method will now use the domain method to get the domain to use, instead of getting the domain information directly from the request environment. jeremyevans-rodauth-b53f402/doc/release_notes/2.16.0.txt000066400000000000000000000012251515725514200227260ustar00rootroot00000000000000= New Features * Rodauth.lib has been added for using Rodauth purely as a library, useful in non-web applications: require 'rodauth' rodauth = Rodauth.lib do enable :create_account, :change_password end rodauth.create_account(login: 'foo@example.com', password: '...') rodauth.change_password(account_id: 24601, password: '...') This is built on top of the internal_request feature, and works by creating a Roda application with the rodauth plugin, and returning the related Rodauth::Auth class. = Other Improvements * The internal_request feature now works correctly for configurations where only_json? is set to true. jeremyevans-rodauth-b53f402/doc/release_notes/2.17.0.txt000066400000000000000000000005131515725514200227260ustar00rootroot00000000000000= Improvements * The jwt_refresh feature now works for unverified accounts when using the verify_account_grace_period feature. * When trying to create an account that already exists but is unverified, Rodauth now returns a 4xx response. * When trying to login to an unverified account, Rodauth now returns a 4xx response. jeremyevans-rodauth-b53f402/doc/release_notes/2.18.0.txt000066400000000000000000000022331515725514200227300ustar00rootroot00000000000000= New Features * When using the json and multifactor auth features, the JSON API can now access the multifactor-manage route to get lists of endpoints for setting up and disabling supported multifactor authentication methods. The JSON API can now also access the multifactor-auth route to get a list of endpoints for multifactor authentication for the currently logged in account. = Other Improvements * In the otp feature, the viewbox: true rqrcode option is now used when creating the QR code. This results in a QR code that is displayed better and is easier to style. This option only has an effect when using rqrcode 2+. * When using the :auth_class option when loading the rodauth plugin, the configuration name is set in the provided auth class, unless the auth class already has a configuration name set. * The example migration now recommends using a partial index on the email column in cases where the database supports partial indexes. Previously, it only recommended it on PostgreSQL. * The argon2 feature now works with argon2 2.1.0. Older versions of Rodauth work with both earlier and later versions of argon2, but not 2.1.0. jeremyevans-rodauth-b53f402/doc/release_notes/2.19.0.txt000066400000000000000000000053621515725514200227370ustar00rootroot00000000000000= New Features * A login_maximum_bytes configuration method has been added, setting the maximum bytes allowed in a login. This was added as login_maximum_length sets the maximum length in characters. It's possible a different number of maximum bytes than maximum characters is desired by some applications, and since the database column size may be enforced in bytes, it's useful to have a check before trying a database query that would raise an exception. This default value for login_maximum_bytes is 255, the same as the default value for login_maximum_length. A login_too_many_bytes_message configuration method has been added for customizing the error message if a login has too many bytes. * password_maximum_length and password_maximum_bytes configuration methods have been added, specifying the maximum size of passwords in characters and bytes, respectively. Both configurations default to nil, meaning no limit, so there is no change in default behavior. The bcrypt algorithm only uses the first 72 bytes of a password, and in some environments it may be desirable to reject passwords over that limit. password_too_long_message and password_too_many_bytes_message configuration methods have been added for customizing the error messages used for passwords that are too long. Note that in most environments, if you want to support passwords over 72 bytes and have the entire password be considered, you should probably use the argon2 feature. = Other Improvements * The subclass created by the internal_request feature is now set to the InternalRequest constant on the superclass, mostly to make identifying it easier in inspect output. * Support has been improved for custom Rodauth::Auth subclasses that load features before the subclass is loaded into Roda, by delaying the call to post_configure until the subclass is loaded into Roda. Among other things, this fixes the use of the internal_request feature in such classes. * Multi-level inheritance of Rodauth::Auth is now supported. This can be useful as a way to share custom authentication settings between multiple Rodauth configurations. However, users of multi-level inheritance should be careful not to load features in subclasses that override custom settings in superclasses. = Other * Rodauth's primary discussion forum is now GitHub Discussions. The rodauth Google Group is still available for users who would prefer to use that instead. = Backwards Compatibility * The addition of login_maximum_bytes with a default value of 255 is backwards incompatible for applications that want to support logins with multibyte characters where the number of characters in the login is at or below 255, but the number of bytes is above 255. jeremyevans-rodauth-b53f402/doc/release_notes/2.2.0.txt000066400000000000000000000031161515725514200226420ustar00rootroot00000000000000= New Features * When using the jwt_refresh feature, you can remove the current refresh token when logging out by submitting the refresh token in the logout request, the same as when submitting the refresh token to obtain a new refresh token. You can also use a value of "all" instead of the refresh token to remove all refresh tokens when logging out. * A rodauth.otp_last_use method has been added to the otp feature, allowing you to determine when the otp was last used. = Other Improvements * When using multifactor authentication, rodauth.authenticated? and rodauth.require_authentication now cache values in the session and do not perform queries every time they are called. * Many guides for common scenarios have been added to the documentation. These augment Rodauth's existing comprehensive feature documentation, which is aimed to be more of a reference and less of a guide. * When the verify_account_grace_period and email_auth features are used with a multifactor authentication feature, and the verify_account_set_password? configuration method is set to true, Rodauth no longer raises a NoMethodError when checking if the session was authenticated. * In the verify_account feature, if verify_account_email_resend returns false indicating no email was sent, an error message is now used, instead of a success message. * In the password_complexity feature, the password_dictionary configuration method was previously ignored if the default password dictionary file existed. * Rodauth and all features that ship with it now have 100% branch coverage. jeremyevans-rodauth-b53f402/doc/release_notes/2.20.0.txt000066400000000000000000000006701515725514200227240ustar00rootroot00000000000000= Improvements * When using the active_sessions and remember features together, doing a global logout will automatically remove the remember key for the account, so the account will no longer be able to automatically create new sessions using the remember key. * The default value of webauthn_rp_id now removes the port from the origin if it exists, since the WebAuthn spec does not allow ports in the relying party identifier. jeremyevans-rodauth-b53f402/doc/release_notes/2.21.0.txt000066400000000000000000000024661515725514200227320ustar00rootroot00000000000000= Improvements * When using the verify_account_grace_period feature, if the grace period has expired for currently logged in session, require_login will clear the session and redirect to the login page. This is implemented by having the unverified_account_session_key store the time of expiration, as an integer. * The previously private require_account method is now public. The method is used internally by Rodauth to check that not only is the current session logged in, but also that the account related to the currently logged in session still exists in the database. The only reason you would want to call require_account instead of require_authentication is if you want to handle cases where there can be logged in sessions for accounts that have been deleted. * Rodauth now avoids an unnecessary bcrypt hash calculation when updating accounts when using the account_password_hash_column configuration method. * When WebAuthn token last use times are displayed, Rodauth now uses a fixed format of YYYY-MM-DD HH:MM:SS, instead of relying on Time#to_s. If this presents an problem for your application, please open an issue and we can add a configuration method to control the behavior. * A typo in the default value of global_logout_label in the active_sessions feature has been fixed. jeremyevans-rodauth-b53f402/doc/release_notes/2.22.0.txt000066400000000000000000000027011515725514200227230ustar00rootroot00000000000000= New Features * Rodauth now ignores parameters containing ASCII NUL bytes ("\0") by default. You can customize this behavior using the null_byte_parameter_value configuration method. * A reset_password_notify feature has been added for emailing users after successful password resets. * External features can now use the email method inside their feature definitions to DRY up the creation of email configuration methods. The email method will setup the following configuration methods for the feature: * ${name}_email_subject * ${name}_email_body * create_${name}_email * send_${name}_email = Other Improvements * The active_sessions feature now correctly handles logouts for sessions that were created before the active_sessions feature was added to the Rodauth configuration. * The change_password_notify feature now works correctly when using template precompilation. * The update_sms method now updates the in-memory sms hash instead of the in-memory account hash. This only has an effect if you are using the sms_codes feature and customizing Rodauth to access one of these hashes after a call to update_sms. = Backwards Compatibility * If your application requires the ability to submit values containing ASCII NUL bytes ("\0") as Rodauth parameters, you should use the new null_byte_parameter_value configuration method to pass the value through unchanged: null_byte_parameter_value do |_, v| v end jeremyevans-rodauth-b53f402/doc/release_notes/2.23.0.txt000066400000000000000000000011641515725514200227260ustar00rootroot00000000000000= Improvements * The otp feature now uses the :use_path option when rendering QR codes, resulting in significantly smaller svg images. * Removing all multifactor authentication methods now removes the fact that the session was authenticated via SMS, if the user used SMS as an authentication method for the current session. * The invalid domain check in the internal_request feature now works correctly when using the rack master branch. * The :httponly cookie option is no longer set automatically in the remember feature if the :http_only cookie option was provided by the user (rack recognizes both options). jeremyevans-rodauth-b53f402/doc/release_notes/2.24.0.txt000066400000000000000000000010641515725514200227260ustar00rootroot00000000000000= New Features * rodauth.otp_available? has been added for checking whether the account is allowed to authenticate with OTP. It returns true when the account has setup OTP and OTP use is not locked out. * rodauth.recovery_codes_available? has been added for checking whether the account is allowed to authenticate using a recovery code. It returns true when there are any available recovery codes for the account to use. = Other Improvements * The otp feature no longer includes the tag for svg images, since that results in invalid HTML. jeremyevans-rodauth-b53f402/doc/release_notes/2.25.0.txt000066400000000000000000000005471515725514200227340ustar00rootroot00000000000000= New Features * You can now disable routing to specific routes by calling the related *_route configuration method with nil or false. The main reason you would want to do this is if you want to load a feature, but only want to use it for internal requests (using the internal_request feature), and not have the feature's routes exposed to users. jeremyevans-rodauth-b53f402/doc/release_notes/2.26.0.txt000066400000000000000000000035571515725514200227410ustar00rootroot00000000000000= New Features * An argon2_secret configuration method has been added to the argon2 feature, supporting argon2's built-in password peppering. = Other Improvements * Links are no longer automatically displayed for routes that are disabled by calling the *_route method with nil. * The QR code used by the otp feature now uses a white background instead of a transparent background, fixing issues when the underlying background is dark. * Input parameter bytesize is now limited to 1024 bytes by default. Parameters larger than that will be ignored, as if they weren't submitted. * The Rodauth::Auth class for internal request classes now uses the same configuration name as the class it is based on. * The session_key_prefix configuration method no longer also prefixes the keys used in the flash hash. * The *_path and *_url methods now return nil when the related *_route method returns nil, indicating the route is disabled. * A more explicit error message is raised when using a feature that requires the hmac_secret being set and not setting hmac_secret. = Backwards Compatibility * If you are using session_key_prefix and flash messages, you will probably need to adjust your code to remove the prefix from the expected flash keys, or manually prefix the flash keys by using the flash_error_key and flash_notice_key configuration methods. * The limiting of input parameter bytesizes by default could potentially break applications that use Rodauth's parameter parsing method to handle parameters that Rodauth itself doesn't handle. You can use the max_param_bytesize configuration method to set a larger bytesize, or use a value of nil with the method for the previous behavior of no limit. Additionally, to customize the behavior if a parameter is over the allowed bytesize, you can use the over_max_bytesize_param_value configuration method. jeremyevans-rodauth-b53f402/doc/release_notes/2.27.0.txt000066400000000000000000000026421515725514200227340ustar00rootroot00000000000000= Improvements * Token ids submitting in requests are now converted to integers if the configuration uses an integer primary key for the accounts table. If the configuration uses a non-integer primary key for the accounts table, the convert_token_id configuration method can be used, which should return the token id converted to the appropriate type, or nil if the token id is not valid for the type. This revised handling avoids raising a database error when an invalid token is submitted. * The button template can now be overridden in the same way that other Rodauth templates can be overridden. * When using the Bootstrap CSS framework, the text field in the Webauthn setup and auth forms is automatically hidden. The text field already had a rodauth-hidden class to make it easy to hide when using other CSS frameworks. * The email_from and email_to methods are now public instead of private. * A nicer error is raised if the Sequel Database object is missing. * A regression in the TOTP QR output that resulted in the QR codes being solid black squares has been fixed (this was fixed in Rodauth 2.26.1). = Backwards Compatibility * The webauth_credentials_for_get method in the webauthn feature has been renamed to webauthn_credentials_for_get for consistency with other methods. The webauth_credentials_for_get method will still work until Rodauth 3, but will issue deprecation warnings. jeremyevans-rodauth-b53f402/doc/release_notes/2.28.0.txt000066400000000000000000000011321515725514200227260ustar00rootroot00000000000000= New Features * A webauthn_key_insert_hash configuration method has been added when using the webauthn feature, making it easier to add new columns to the webauthn key data, such as a custom name for the authenticator. = Other Improvements * When using the verify_account_grace_period feature, logged_in? now returns false for sessions where the grace period has expired. * When using the internal_request and reset_password features, submitting an internal request for an invalid login no longer tries to render a reset password request form. * The password_hash method is now public. jeremyevans-rodauth-b53f402/doc/release_notes/2.29.0.txt000066400000000000000000000021761515725514200227400ustar00rootroot00000000000000= New Features * When using the remember feature, by default, the remember deadline is extended while logged in, if it hasn't been extended in the last hour * An account! method has been added, which will return the hash for the account if already retrieved, or attempt to retrieve the account hash using the currently logged in session if not. Because of the ambiguity in the provenance of the returned account hash, callers should be careful when using this method. * A remove_active_session method has been added. You can call this method with a specific session id, and it will remove the related active session. * A render: false plugin option is now support, which will disable the automatic loading of the render plugin. This should only be used if you are completely replacing Rodauth's view rendering with your own. = Other Improvements * When logging in when using the active_sessions feature, if there is a current active session, it is removed before a new active session is created. This prevents some stale active sessions from remaining in the database (which would eventually be cleaned up later). jeremyevans-rodauth-b53f402/doc/release_notes/2.3.0.txt000066400000000000000000000027421515725514200226470ustar00rootroot00000000000000= New Features * Configuration methods have been added for easier validation of logins when logins must be valid email addresses (the default): * login_valid_email?(login) can be used for full control of determining whether the login is valid. * login_email_regexp can be used to set the regexp used in the default login_valid_email? check. * login_not_valid_email_message can be used to set the field error message if the login is not a valid email. Previously, this value was hardcoded and not translatable. * The {create,drop}_database_authentication_functions now work correctly with uuid keys on PostgreSQL. All other parts of Rodauth already worked correctly with uuid keys. = Other Improvements * The before_jwt_refresh_route hook is now called before the route is taken. Previously, the configuration method had no effect. * rodauth.login can now be used by external code to login the current account (the account that rodauth.account returns). This should be passed the authentication type string used to login, such as password. * The jwt_refresh route now returns an error for requests where a valid access token for a logged in session is not provided. You can use the jwt_refresh_without_access_token_message and jwt_refresh_without_access_token_status configuration methods to configure the error response. * The new refresh token is now available to the after_refresh_token hook by looking in json_response[jwt_refresh_token_key]. jeremyevans-rodauth-b53f402/doc/release_notes/2.30.0.txt000066400000000000000000000011311515725514200227160ustar00rootroot00000000000000= New Features * A webauthn_autofill feature has been added to allow autofilling webauthn credentials during login (also known as conditional mediation). This allows for easier login using passkeys. This requires a supported browser and operating system on the client side to work. = Other Improvements * The load_memory method in the remember feature no longer raises a NoMethodError if the there is a remember cookie, the session is already logged in, and the account no longer exists. The load_memory method now removes the remember cookie and clears the session in that case. jeremyevans-rodauth-b53f402/doc/release_notes/2.31.0.txt000066400000000000000000000032231515725514200227230ustar00rootroot00000000000000= New Features * The internal_request feature now supports WebAuthn, using the following methods: * With the webauthn feature: * webauthn_setup_params * webauthn_setup * webauthn_auth_params * webauthn_auth * webauthn_remove * With the webauthn_login feature: * webauthn_login_params * webauthn_login * A webauthn_login_user_verification_additional_factor? configuration method has been added to the webauthn_login feature. By default, this method returns false. If you configure the method to return true, and the WebAuthn credential provided specifies that it verified the user, then this will treat the user verification as a second factor, so the user will be considered multifactor authenticated after successful login. You should only set this method to true if you consider the WebAuthn user verification strong enough to be a independent factor. * A json_response_error? configuration method has been added to the json feature. This should return whether the current response should be treated as an error by the json feature. By default, it is true if json_response_error_key is set in the response, since that is the default place that Rodauth stores errors when using the json feature. * A webauthn_invalid_webauthn_id_message configuration method has been added for customizing the error message used for invalid WebAuthn IDs. = Other Improvements * The argon2 feature now supports setting the Argon2 p_cost if argon2 2.1+ is installed. * An :invalid_webauthn_id error reason is now used for invalid WebAuthn IDs. * The clear_session method now works as expected for internal requests. jeremyevans-rodauth-b53f402/doc/release_notes/2.32.0.txt000066400000000000000000000057331515725514200227340ustar00rootroot00000000000000= New Features * Rodauth now supports secret rotation using the following configuration methods: * hmac_old_secret * argon2_old_secret (argon2 feature) * jwt_old_secret (jwt feature) You can use these methods to specify the previous secret when rotating secrets. Note that full secret rotation (where you can remove use of the old secret) may not be simple. Here are some cases that require additional work: * Rotating the argon2 secret requires the use of the update_password_hash feature. You cannot remove the use of argon2_old_secret unless every user who created a password under the old secret has logged in after the new secret was added. Removing the old secret before a user has logged in after the new secret was added will invalidate the password for the user. Thus, full rotation of the argon2 secret requires invalidating passwords for inactive accounts. * Full rotating of the hmac secret when using the remember feature requires that all remember cookies created under the previous secret has been removed. By default, remember cookies expire in 2 weeks, but it is possible to set them much longer. * Full rotation of the hmac secret when using the verify_account feature requires invalidating old verify account links, since verify account links do not have a deadline. However, after old verify account links have been invalidated, a user can request a new verify account link, which will work. * Full rotation of the hmac secret when using the otp feature requires disabling otp and reenabling otp. The otp_valid_code_for_old_secret configuration method has been added, which can be used to handle cases where a user successfully authenticated via TOTP using the old secret. This can be used to direct them to a page to remove the TOTP authenticator and then setup a new TOTP authenticator. * Many *_response configuration methods have been added, which allow users to override Rodauth's default behavior in successful cases of setting a flash notice and then redirecting. Note that using these configuration methods correctly requires that they halt request processing. You cannot just have them return a response body. You can use the return_response method to set the response body and halt processing. * An sms_needs_confirmation_notice_flash configuration method has been added, for setting the flash notice when setting up SMS authentication. By default, it uses the sms_needs_confirmation_error_flash value. = Other Improvements * The argon2 feature no longer uses the Base64 constant. Previously, it uses the library without attempting to require the base64 library, which would break if the base64 library was not already required. * Rodauth's documentation now recommends against the use of the argon2 feature, because for typical interactive login uses (targetting sub-200ms response times), argon2 provides significantly worse security than bcrypt. jeremyevans-rodauth-b53f402/doc/release_notes/2.33.0.txt000066400000000000000000000014471515725514200227330ustar00rootroot00000000000000= Improvements * Rodauth no longer accidentally confirms an SMS number upon valid authentication by an alternative second factor. * Rodauth now automatically expires SMS confirmation codes after 24 hours by default. You can use the sms_confirm_deadline configuration method to adjust the deadline. Previously, if an invalid SMS number was submitted, or the SMS confirm code was never received, it was not possible to continue SMS setup without administrative intervention. * Rodauth no longer overwrites existing primary key values when inserting new accounts. This fixes cases such as setting account primary key values to UUIDs before inserting. * When submitting a request to a valid endpoint with a missing token, Rodauth now returns an error response instead of a 404 response. jeremyevans-rodauth-b53f402/doc/release_notes/2.34.0.txt000066400000000000000000000030011515725514200227200ustar00rootroot00000000000000= New Features * A rodauth.current_route method has been added for returning the route name symbol (if rodauth is currently handling the route). This makes it simpler to write code that extends Rodauth and works with applications that use override the default route names. * A remove_all_active_sessions_except_for method has been added to the active_sessions feature, which removes all active sessions for the current account, except for the session id given. * A remove_all_active_sessions_except_current method has been added to the active_sessions feature, which removes all active sessions for the current account, except for the current session. = Improvements * Rodauth now supports overriding webauthn_rp_id in the webauthn feature. * When using the login feature, Rodauth now defaults require_login_redirect to use the path to the login route, instead of /login. * When setting up multifactor authentication, Rodauth now handles the case where account has been deleted, instead of raising an exception. * When a database connection is not available during startup, Rodauth now handles that case instead of raising an exception. Note that in this case, Rodauth cannot automatically setup a conversion of token ids to integer, since it cannot determine whether the underlying database column uses an integer type. * When using WebAuthn 3+, Rodauth no longer defines singleton methods to work around limitations in WebAuthn. Instead, it uses public APIs that were added in WebAuthn 3. jeremyevans-rodauth-b53f402/doc/release_notes/2.35.0.txt000066400000000000000000000015471515725514200227360ustar00rootroot00000000000000= New Features * A throw_rodauth_error method has been added to make it easier for external extensions to throw the expected error value without setting a field error. = Improvements * If an account is not currently logged in, but Rodauth knows the related account id, remove_all_active_sessions and related methods in the active_sessions plugin will now remove sessions for the related account. * When using the internal_request feature and subclasses, internal_request_configuration blocks in superclasses are now respected when creating the internal request class for a subclass. When creating the internal request in the subclass, this behaves as if all internal_request_configuration blocks were specified directly in the subclass. * An ignored block warning on Ruby 3.4 is now avoided by having Rodauth.load_dependencies accept a block. jeremyevans-rodauth-b53f402/doc/release_notes/2.36.0.txt000066400000000000000000000025771515725514200227430ustar00rootroot00000000000000= New Features * An otp_unlock feature has been added, allowing a user to unlock TOTP authentication with 3 consecutive successful TOTP authentications. Previously, once TOTP authentication was locked out, there was no way for the user to unlock it. Any unsuccessful TOTP authentication during the unlock process prevents unlocks attempts for a configurable amount of time (15 minutes by default). By default, this limits brute force attempts to unlock TOTP authentication to less than 10^2 per day, with the odds of a successful unlock in each attempt being 1 in 10^18. * An otp_lockout_email feature has been added for emailing the user when their TOTP authentication has been locked out or unlocked, and when there has been a failed unlock attempt. * An otp_modify_email feature has been added for emailing the user when TOTP authentication has been setup or disabled for their account. * A webauthn_modify_email feature has been added for emailing the user when a WebAuthn authenticator has been added or removed from their account. * An account_from_id configuration method has been added for loading the account with the given account id. * A strftime_format configuration method has been added for configuring how Time values are formatted for display to the user. = Improvements * The internal_request feature now works with Roda's path_rewriter plugin. jeremyevans-rodauth-b53f402/doc/release_notes/2.37.0.txt000066400000000000000000000053771515725514200227450ustar00rootroot00000000000000= New Features * Both route block scope rodauth and r.rodauth now call default_rodauth_name on the scope to get the rodauth configuration to use. This can significantly simplify installations using multiple rodauth configurations, since you can now define the logic for which rodauth configuration to use in a single place, instead of having to specify the non-default configuration name explicitly in all cases where you want to use it. Here's an example of possible use: attr_reader :default_rodauth_name route do |r| r.on 'secondary' do @default_rodauth_name = :secondary r.rodauth # will use the :secondary configuration end r.rodauth # will use the default configuration end * A normalize_login configuration method has been added, for normalizing submitted login parameters. You can use this to force login parameters to lowercase, which can be useful if storing the logins in a case-sensitive column (the default on SQLite). normalize_login(&:downcase) This can also be used for application-specific normalization. * A two_factor_partially_authenticated? method has been added, which allows you to more easily detect the partially authenticated case. This method returns true if the session is logged in and the related account has setup two factor authentication, but the session has not yet been authenticated by multiple factors. * A webauthn_autofill? configuration method has been added to the webauthn_autofill feature. This allows for disabling the autofill UI on the login page, while still keeping other parts of the webauthn_autofill feature. * A login_confirmation_matches? configuration method has been added, allowing you to customize the confirmation comparison, so you can continue to use a case-sensitive comparison if you would like (the comparison is now case-insensitive by default, see below). = Improvements * The login confirmation comparison is now done in a case-insensitive manner. Previously, while a case-insensitive column was used in the database (on most databases), the confirmation comparison used a case-sensitive comparison. * The jwt feature will no longer call clear_session on the scope if the request uses JWTs instead of scope sessions for session storage. * CSRF protection is no longer enforced for JSON requests when using the Roda route_csrf plugin. Previously, it was not enforced for JWT requests, but was enforced for other JSON requests. CORS restrictions are sufficient to prevent typical CSRF attacks for JSON requests. If you would like to continue to enforce CSRF protection for JSON requests when using Roda's route_csrf plugin: check_csrf? true * The size of the gem has been reduced 50% by removing documentation. jeremyevans-rodauth-b53f402/doc/release_notes/2.38.0.txt000066400000000000000000000034201515725514200227310ustar00rootroot00000000000000= New Features * Rodauth now automatically supports fixed locals in templates if using Roda 3.88+ and Tilt 2.6+. This allows you to use the Roda default_fixed_locals: '()' template option without breaking Rodauth. If the default fixed locals support breaks your Rodauth configuration, such as if you are overriding Rodauth templates and modifying the local variables they accept, you can disable the use of fixed locals in your Rodauth configuration: use_template_fixed_locals? false * Rodauth::ConfigurationError has been added, and issues that Rodauth believes are configuration errors now use this exception class. = Other Improvements * The following methods are now public: * has_password? * email_auth_email_recently_sent? * unlock_account_email_recently_sent? * reset_password_email_recently_sent? * verify_account_email_recently_sent? This makes it supported to call these methods and use the result in your own code. * The verify-account-resend page now works if verify_account_resend_explanatory_text calls verify_account_email_recently_sent?. Rodauth does not do that by default, but if you override verify_account_resend_explanatory_text to use different text depending on whether the email was recently sent, direct navigations to the verify-account-resend page previously failed. * Rodauth now uses JWT.gem_version to check the JWT gem version, which works with JWT 2.10.0. JWT 2.10.1 restored the constants Rodauth used to check the version, but this allows the JWT to remove such constants again in the future without breaking Rodauth. = Backwards Compatibility * The change to use Rodauth::ConfigurationError can break code that rescued other exception classes, such as ArgumentError, RuntimeError, or NotImplementedError. jeremyevans-rodauth-b53f402/doc/release_notes/2.39.0.txt000066400000000000000000000014511515725514200227340ustar00rootroot00000000000000= Improvements * Rodauth now supports Roda's plain_hash_response_headers plugin on Rack 3+, by using lowercase response header keys, instead of relying on Roda's default conversion of response header keys to lowercase. * When setting login_return_to_requested_location? to true, by default, Rodauth will no longer return to the requested location if it is more than 2048 bytes in size. This is to avoid exceeding the 4K cookie size limit. You can modify this limit using the new login_return_to_requested_location_max_path_size configuration method. * Rodauth now uses JSON.generate instead of JSON.fast_generate to avoid a deprecation warning in recent json gem versions. * Rodauth now uses allowed_origins instead of origin when using WebAuthn 3.4+ to avoid a deprecation warning. jeremyevans-rodauth-b53f402/doc/release_notes/2.4.0.txt000066400000000000000000000020161515725514200226420ustar00rootroot00000000000000= New Features * A password_pepper feature has been added. This allows you to use a secret key (called a pepper) to append to passwords before hashing and hash checking. Using this approach, if an attacker obtains the password hash, it is unusable for cracking unless they can also get access to the pepper. The password_pepper feature also supports a list of previous peppers that can be used to implement secret rotation and to support compatibility with unpeppered passwords. Rodauth by default uses database functions for password hash checking on PostgreSQL, MySQL, and Microsoft SQL Server, which in general provides more security than a password pepper, but both approaches can be used simultaneously. * A session_key_prefix configuration method has been added for prefixing the values of all default session keys. This can be useful if you are using multiple Rodauth configurations in the same application and want to make sure the session keys for the separate configurations do not overlap. jeremyevans-rodauth-b53f402/doc/release_notes/2.40.0.txt000066400000000000000000000011641515725514200227250ustar00rootroot00000000000000= New Features * A reset_password_request_for_unverified_account configuration method is now available. This allows you to configure the behavior if an unverified account requests a password reset. If the method is not used, the default behavior remains to show an error for the login parameter. = Other Improvements * In the otp_unlock feature, instead of using a meta refresh tag in the HTML, a refresh HTTP header is used. This should fix the page not automatically refreshing in some browsers. You can customize this behavior by using the otp_unlock_not_available_set_refresh_header configuration method. jeremyevans-rodauth-b53f402/doc/release_notes/2.41.0.txt000066400000000000000000000034621515725514200227310ustar00rootroot00000000000000= Improvements * When making a change to an account (e.g. changing a login), tokens for the account are now cleared or reset. Previously, if you requested a password reset, then requested a login change, and then changed the login, the password reset link would still be valid after the login change was made, until the password reset token expired (default: 1 day). If the reason you are chaging your login is that you suspect your email may be compromised, you probably wouldn't want the reset password link to still be valid after the login change. The following account changes trigger clearing of tokens: * change login * close account * reset password * unlock account * verify account The following account tokens are cleared upon such changes: * active sessions (other than logged in session) * email auth * jwt refresh (if not logged in) * lockout (updates token if it exists) * remember (creates and uses new remember token if logged in via remember token) * reset password * single session (if not logged in) * verify account * verify login change This is a more secure default, and it is expected that it will not negatively affect the vast majority of Rodauth installations. However, due to Rodauth's very configurable nature, it is possible it will cause issues for some installations. = Backwards Compatibility * If clearing tokens on account change causes problems for your application, you can revert to clearing tokens only on account close: clear_tokens do |reason| super(reason) if reason == :close_account end * If you were calling after_close_account directly to clear tokens, you should now also call: clear_tokens(:close_account) As some token clearing now occurs in clear_tokens and not in after_close_account. jeremyevans-rodauth-b53f402/doc/release_notes/2.42.0.txt000066400000000000000000000003361515725514200227270ustar00rootroot00000000000000= Improvements * Rodauth now avoids mixing string and symbol keys in a hash used to create a JWT in the jwt_refresh feature. This avoids warnings in the current json gem, and will avoid errors in json gem version 3+. jeremyevans-rodauth-b53f402/doc/release_notes/2.43.0.txt000066400000000000000000000004651515725514200227330ustar00rootroot00000000000000= New Features * A reset_password_verifies_account feature has been added. With this feature enabled, unverified accounts are allowed to reset their passwords, and a valid password reset verifies the account, as it proves ownership of the account's email (just as normal account verification would). jeremyevans-rodauth-b53f402/doc/release_notes/2.5.0.txt000066400000000000000000000016271515725514200226520ustar00rootroot00000000000000= New Features * A login_return_to_requested_location_path configuration method has been added to the login feature. This controls the path to redirect to if using login_return_to_requested_location?. By default, this is the same as the fullpath of the request that required login if that request was a GET request, and nil if that request was not a GET request. Previously, the fullpath of that request was used even if it was not a GET request, which caused problems as browsers use a GET request for redirects, and it is a bad idea to redirect to a path that may not handle GET requests. * A change_login_needs_verification_notice_flash configuration method has been added to the verify_login_change feature, for allowing translations when using the feature and not using the change_login_notice_flash configuration method. = Other Improvements * new_password_label is now translatable. jeremyevans-rodauth-b53f402/doc/release_notes/2.6.0.txt000066400000000000000000000027051515725514200226510ustar00rootroot00000000000000= New Features * An around_rodauth configuration method has been added, which is called around all Rodauth actions. This configuration method is passed a block, and is useful for cases where you want to wrap Rodauth's handling of the request. For example, if you had a method named time_block in your Roda scope that timed block execution and added a response header, you could time Rodauth actions using something like: around_rodauth do |&block| scope.time_block('Rodauth') do super(&block) end end * The allow_refresh_with_expired_jwt_access_token? configuration has been added to the jwt_refresh feature, allowing refreshing with an expired but otherwise valid access token. When using this method, it is required to have an hmac_secret specified, so that Rodauth can make sure the access token matches the refresh token. = Other Improvements * The javascript for setting up a WebAuthn token has been fixed to allow it to work correctly if there is already an existing WebAuthn token for the account. * The rodauth.setup_account_verification method has been promoted to public API. You can use this method for automatically sending account verification emails when automatically creating accounts. * Rodauth no longer loads the same feature multiple times into a single configuration. This didn't cause any problems before, but could result in duplicate entries when looking at the loaded features. jeremyevans-rodauth-b53f402/doc/release_notes/2.7.0.txt000066400000000000000000000025621515725514200226530ustar00rootroot00000000000000= New Features * An auto_remove_recovery_codes? configuration method has been added to the recovery_codes feature. This will automatically remove recovery codes when the last multifactor authentication type other than the recovery codes has been removed. * The jwt_access_expired_status and expired_jwt_access_token_message configuration methods have been added to the jwt_refresh feature, for supporting custom statuses and messages for expired tokens. = Other Improvements * Rodauth will no longer attempt to require a feature that has already been required. Related to this is you can now use a a custom Rodauth feature without a rodauth/features/*.rb file in the Ruby library path, as long as you load the feature manually. * Rodauth now avoids method redefinition warnings in verbose warning mode. As Ruby 3 is dropping uninitialized instance variable warnings, Rodauth will be verbose warning free in Ruby 3. = Backwards Compatibility * The default remember cookie path is now set to '/'. This fixes usage in the case where rodauth is loaded under a subpath of the application (which is not the default behavior). Unfortunately, this change can negatively affect cases where multiple rodauth configurations are used in separate paths on the same domain. In these cases, you should now use remember_cookie_options and include a :path option. jeremyevans-rodauth-b53f402/doc/release_notes/2.8.0.txt000066400000000000000000000016771515725514200226620ustar00rootroot00000000000000= Improvements * HttpOnly is now set by default on the remember cookie, so it is no longer accessible from Javascript. This is a more secure approach that makes applications using Rodauth's remember feature less vulnerable in case they are subject to a separate XSS attack. * When using the jwt feature, rodauth.clear_session now clears the JWT session even when the Roda sessions plugin was in use. In most cases, the jwt feature is not used with the Roda sessions plugin, but in cases where the same application serves as both an JSON API and as a HTML site, it is possible the two may be used together. = Backwards Compatibility * As the default remember cookie :httponly setting is now set to true, applications using Rodauth that expected to be able to access the remember cookie from Javascript will no longer work by default. In these cases, you should now use remember_cookie_options and include a :httponly=>false option. jeremyevans-rodauth-b53f402/doc/release_notes/2.9.0.txt000066400000000000000000000014431515725514200226520ustar00rootroot00000000000000= New Features * A json feature has been extracted from the existing jwt feature. This feature allows for the same JSON API previously supported by the JWT feature, but stores the session information in the Rack session instead of in a separate JWT. This makes it significantly easier to have certain pages use the JSON API, and other pages the HTML forms. = Other Improvements * If the remember cookie is created in an SSL request, the Secure flag is added by default, so the cookie will not be transmitted in non-SSL requests. = Backwards Compatibility * Rodauth configurations that use the remember feature and support requests over both http and https and want to have the remember cookie transmitted over both should now include :secure=>false in remember_cookie_options. jeremyevans-rodauth-b53f402/doc/remember.rdoc000066400000000000000000000134521515725514200212650ustar00rootroot00000000000000= Documentation for Remember Feature The remember feature allows for token-based autologin for users. Calling +rodauth.remember_login+ for an authenticated session will create a token for the current account and store it in a cookie. You can then add the following code to your routing block to automatically login users from that token if the session has expired: rodauth.load_memory By default, the remember feature just supports a form that the user can use to change their remember settings for the current browser. They can either enable remembering for the browser, forget it for the browser, or disable it completely so that any remembering for other browsers is removed as well. In some cases, you may want to automatically remember users and not require users to turn it on manually. If you want to automatically remember users on login: after_login do remember_login end The remember feature records which sessions were autologged in via the remember cookie. If you have sections where you want to add more security, you can use the confirm password feature to request password authentication for sessions autologged in via a remember token: rodauth.require_password_authentication == Auth Value Methods extend_remember_deadline? :: Whether to extend the remember token deadline when the user is autologged in via remember token and every +extend_remember_deadline_period+ seconds while logged in. extend_remember_deadline_period :: The amount of seconds to wait before extending remember token deadline when +extend_remember_deadline?+ is true (3600 by default). raw_remember_token_deadline :: A deadline before which to allow a raw remember token to be used. Allows for graceful transition for when +hmac_secret+ is first set. remember_additional_form_tags :: HTML fragment containing additional form tags to use on the change remember setting form. remember_button :: The text to use for the change remember settings button. remember_cookie_key :: The cookie name to use for the remember token. remember_cookie_options :: Any options to set for the remember cookie. By default, the `:path` cookie option is set to `/` and `:httponly` is set to `true`. Also, `:secure` is set to `true` by default if the current request is an HTTPS request. remember_deadline_column :: The column name in the +remember_table+ storing the deadline after which the token will be ignored. remember_deadline_extended_session_key :: The session key set if the remember deadline token is being extended. remember_deadline_interval :: The amount of time for which to remember accounts, 14 days by default. Only used if +set_deadline_values?+ is true. remember_disable_label :: The label for disabling remembering. remember_disable_param_value :: The parameter value for disabling remembering. remember_error_flash :: The flash error to show if there is an error changing a remember setting. remember_forget_label :: The label for turning off remembering. remember_forget_param_value :: The parameter value for turning off remembering. remember_id_column :: The id column in the +remember_table+, should be a foreign key referencing the accounts table. remember_key_column :: The remember key/token column in the +remember_table+. remember_notice_flash :: The flash notice to show after remember setting has been updated. remember_page_title :: The page title to use on the change remember settings form. remember_param :: The parameter name to use for the remember password settings choice. remember_period :: The additional time to extend the remember deadline if extending remember deadlines. remember_redirect :: Where to redirect after changing the remember settings. remember_remember_label :: The label for turning on remembering. remember_remember_param_value :: The parameter value for switching on remembering. remember_route :: The route to the change remember settings action. Defaults to +remember+. remember_table :: The name of the remember keys table. == Auth Methods add_remember_key :: Add a remember key for the current account to the remember keys table. after_load_memory :: Run arbitrary code after autologging in an account via a remember token. after_remember :: Run arbitrary code after changing the remember settings. before_load_memory :: Run arbitrary code before autologging in an account via a remember token. before_remember :: Run arbitrary code before changing the remember settings. before_remember_route :: Run arbitrary code before handling the remember route. disable_remember_login :: Disable the remember key token, clearing the token from the database so future connections with the token will not be recognized. forget_login :: Forget the current remember token, deleting the related cookie. Other browsers that have the cookie cached can still use it login. generate_remember_key_value :: A random string to use as the remember key. get_remember_key :: Retrieve the remember key from the database. load_memory :: If the remember key cookie is included in the request, and the user is not currently logged in, check the remember keys table and autologin the user if the remember key cookie matches the current remember key for the account. This method needs to be called manually inside the Roda route block to autologin users. logged_in_via_remember_key? :: Whether the current session was logged in via a remember key. remembered_session_id :: The session_id which is validly remembered, if any. remember_key_value :: The current value of the remember key/token. remember_login :: Set the cookie containing the remember token, so that future sessions will be autologged in. remember_response :: Return a response after successfully changing remember settings. By default, redirects to +remember_redirect+. remember_view :: The HTML to use for the change remember settings form. remove_remember_key(id_value=account_id) :: Delete the related remember key from the database. jeremyevans-rodauth-b53f402/doc/reset_password.rdoc000066400000000000000000000142251515725514200225320ustar00rootroot00000000000000= Documentation for Reset Password Feature The reset password feature implements password resets. If the user enters an invalid password, they will be displayed a form where they can request a password reset. Submitting that form will send an email containing a link, and that link will taken them to a password reset form. Depends on the login feature. == Auth Value Methods no_matching_reset_password_key_error_flash :: The flash error message to show if attempting to access the reset password form with an invalid key. reset_password_additional_form_tags :: HTML fragment containing additional form tags to use on the reset password form. reset_password_autologin? :: Whether to autologin the user after successfully resetting a password, false by default. reset_password_button :: The text to use for the reset password button. reset_password_deadline_column :: The column name in the +reset_password_table+ storing the deadline after which the token will be ignored. reset_password_deadline_interval :: The amount of time for which to allow users to reset their passwords, 1 day by default. Only used if +set_deadline_values?+ is true. reset_password_email_last_sent_column :: The email last sent column in the +reset_password_table+. Set to nil to always send a reset password request email when requested. reset_password_email_recently_sent_error_flash :: The flash error to show if not sending reset password request email because one has been sent recently. reset_password_email_recently_sent_redirect :: Where to redirect if not sending reset password request email because one has been sent recently. reset_password_email_sent_notice_flash :: The flash notice to show after a reset password request email has been sent. reset_password_email_sent_redirect :: Where to redirect after sending a reset password request email. reset_password_email_subject :: The subject to use for the reset password request email. reset_password_error_flash :: The flash error to show after resetting a password. reset_password_explanatory_text :: The text to display above the button to request a password reset. reset_password_id_column :: The id column in the +reset_password_table+, should be a foreign key referencing the accounts table. reset_password_key_column :: The reset password key/token column in the +reset_password_table+. reset_password_key_param :: The parameter name to use for the reset password key. reset_password_notice_flash :: The flash notice to show after resetting a password. reset_password_page_title :: The page title to use on the reset password form. reset_password_redirect :: Where to redirect after resetting a password. reset_password_request_additional_form_tags :: HTML fragment containing additional form tags to use on the reset password request form. reset_password_request_button :: The text to use for the reset password request button. reset_password_request_error_flash :: The flash error to show if not able to send a reset password request email. reset_password_request_link_text :: The text to use for a link to the page to request a password reset. reset_password_request_page_title :: The page title to use on the reset password request form. reset_password_request_route :: The route to the reset password request action. Defaults to +reset-password-request+. reset_password_route :: The route to the reset password action. Defaults to +reset-password+. reset_password_session_key :: The key in the session to hold the reset password key temporarily. reset_password_skip_resend_email_within :: The number of seconds before sending another reset password request email, if +reset_password_email_last_sent_column+ is set. reset_password_table :: The name of the reset password keys table. == Auth Methods account_from_reset_password_key(key) :: Retrieve the account using the given reset password key, or return nil if no account matches. after_reset_password :: Run arbitrary code after successfully resetting a password. after_reset_password_request :: Run arbitrary code after sending the reset password request email. before_reset_password :: Run arbitrary code before resetting a password. before_reset_password_request :: Run arbitrary code before sending the reset password request email. before_reset_password_request_route :: Run arbitrary code before handling a reset password request route. before_reset_password_route :: Run arbitrary code before handling a reset password route. create_reset_password_email :: A Mail::Message for the reset password request email. create_reset_password_key :: Add the reset password key data to the database. get_reset_password_email_last_sent :: Get the last time a reset password request email is sent, or nil if there is no last sent time. get_reset_password_key(id) :: Get the password reset key for the given account id from the database. login_failed_reset_password_request_form :: The HTML to use for a form to request a password reset, shown on the login page after the user tries to login with an invalid password. remove_reset_password_key :: Remove the reset password key for the current account, run after successful password reset. reset_password_email_body :: The body to use for the reset password request email. reset_password_email_link :: The link to the reset password form in the reset password request email. reset_password_email_sent_response :: Return a response after successfully sending a password reset email. By default, redirects to +reset_password_email_sent_redirect+. reset_password_key_insert_hash :: The hash to insert into the +reset_password_table+. reset_password_key_value :: The reset password key for the current account. reset_password_request_for_unverified_account :: What to do if there is a request to reset a password for an unverified account. By default, shows an error for the login parameter. reset_password_request_view :: The HTML to use for the reset password request form. reset_password_response :: Return a response after successfully resetting a password. By default, redirects to +reset_password_redirect+. reset_password_view :: The HTML to use for the reset password form. send_reset_password_email :: Send the reset password request email. set_reset_password_email_last_sent :: Set the last time a reset password request email is sent. jeremyevans-rodauth-b53f402/doc/reset_password_notify.rdoc000066400000000000000000000014041515725514200241150ustar00rootroot00000000000000= Documentation for Reset Password Notify Feature The reset password notify feature emails the user after the user has reset their password. The user has already been sent a reset password email by this point, so they know a password reset was requested, but this feature allows for confirming that the password reset process was completed. Depends on the reset_password feature. == Auth Value Methods reset_password_notify_email_subject :: The subject to use for the reset password notify email. reset_password_notify_email_body :: The body to use for the reset password notify email. == Auth Methods create_reset_password_notify_email :: A Mail::Message for the reset password notify email. send_reset_password_notify_email :: Send the reset password notify email. jeremyevans-rodauth-b53f402/doc/reset_password_verifies_account.rdoc000066400000000000000000000004541515725514200261410ustar00rootroot00000000000000= Documentation for Reset Password Feature The reset password verifies account feature depends on both the reset password and verify account features, and makes it so that a valid password reset implicitly operates as an account verification, since it proves ownership of the related email address. jeremyevans-rodauth-b53f402/doc/session_expiration.rdoc000066400000000000000000000030361515725514200234110ustar00rootroot00000000000000= Documentation for Session Expiration Feature The session expiration feature allows setting an inactivity timeout and a max lifetime for sessions. When this feature is used, you should use +rodauth.check_session_expiration+ at the top (or other appropriate place) in your routing tree. route do |r| rodauth.check_session_expiration r.rodauth # ... end When checking session expiration, if the last activity was more than the inactivity timeout, or the session was created more the maximum lifetime ago, the session is cleared, and the user is redirected to the login page. == Auth Value Methods max_session_lifetime :: The maximum number of seconds since session creation that sessions will be valid for, regardless of session activity. 86400 by default (1 day). session_created_session_key :: The session key storing the session creation timestamp. session_expiration_default :: Whether to expire sessions that don't have the created at or last activity at timestamps set, true by default. session_expiration_error_flash :: The flash error to show if a session expires. session_expiration_error_status :: The error status to use when a JSON request is made and the session has expired, 401 by default. session_expiration_redirect :: Where to redirect if a session expires. session_inactivity_timeout :: The maximum number of seconds allowed since the last activity before the session will be considered invalid. 1800 by default (30 minutes). session_last_activity_session_key :: The session key storing the last session activity timestamp. jeremyevans-rodauth-b53f402/doc/single_session.rdoc000066400000000000000000000043061515725514200225110ustar00rootroot00000000000000= Documentation for Single Session Feature The single session feature stores the key for the session in a database table whenever a user logs in to the system. In your routing block, you can check that the session key given matches the stored key by doing: rodauth.check_single_session It is not recommended to use this feature unless you have a policy that requires it. Many users find it useful to be able to have multiple concurrent sessions, and restricting this ability does not make things more secure. You can use the active_sessions feature for something with similar behavior but that allows for concurrent sessions. One of the side benefits with this feature is that logouts reset the single session key, so attempts to reuse the previous session after logout no longer work. == Auth Value Methods allow_raw_single_session_key? :: Whether to allow a raw single session key to be accepted, should only be enabled for graceful transition when +hmac_secret+ is first set. inactive_session_error_status :: The error status to use when a JSON request is made and the session is no longer active, 401 by default. single_session_error_flash :: The flash error to display if the current session is no longer the active session for the account. single_session_id_column :: The column in the +single_session_table+ containing the account id. single_session_key_column :: The column in the +single_session_table+ containing the single session key. single_session_redirect :: Where to redirect if the current session is no longer the active session for the account. single_session_session_key :: The session key name to use for storing the single session key. single_session_table :: The database table storing single session keys. == Auth Methods currently_active_session? :: Whether the current session is the active session for the user. no_longer_active_session :: The action to take if the current session is no longer the active session for the user. reset_single_session_key :: Reset the single session key for the user, by default to a new random key. update_single_session_key :: Update the single session key in the current session and in the database, reflecting that the current session is the active session for the user. jeremyevans-rodauth-b53f402/doc/sms_codes.rdoc000066400000000000000000000302531515725514200214440ustar00rootroot00000000000000= Documentation for SMS Codes Feature The sms codes feature allows for multifactor authentication via codes provided via SMS messages. It is usually used as a backup if other multifactor authentication is not available or has been locked out, but it can be used as the primary multifactor authentication method. This feature allows users to register their mobile phone number with the system, confirm that they can receive SMS messages on the mobile phone number they have registered, request SMS authentication codes, authenticate via SMS codes, and disable SMS authentication. While this feature sets up all of the infrastructure needed to support SMS authentication, it doesn't handle sending SMS messages itself. There are many ruby libraries that send SMS messages, and you can choose which one to use. When using this feature, you must use the +sms_send+ configuration method and send the SMS using whatever SMS library you prefer: sms_send do |phone_number, message| # ... end == Auth Value Methods no_current_sms_code_error_flash :: The flash error to show when going to the SMS authentication page and no current SMS authentication code is available. sms :: A hash of SMS information for the user, if SMS authentication has been setup. sms_already_setup_error_flash :: The flash error to show when going to a page to setup SMS authentication if SMS authentication has already been setup. sms_already_setup_error_status :: The response status to use when going to a page to setup SMS authentication if SMS authentication has already been setup, 403 by default. sms_already_setup_redirect :: Where to redirect when going to a page to setup SMS authentication if SMS authentication has already been setup. sms_auth_additional_form_tags :: HTML fragment containing additional form tags when authenticating via SMS. sms_auth_button :: Text to use for button on the form to authenticate via SMS. sms_auth_code_length :: The length of SMS authentication codes, 6 by default. sms_auth_link_text :: The text to use for the link from the multifactor auth page. sms_auth_page_title :: The page title to use on the form to authenticate via SMS code. sms_auth_redirect :: Where to redirect if SMS authentication is needed. sms_auth_route :: The route to the SMS authentication action. Defaults to +sms-auth+. sms_code_allowed_seconds :: The number of seconds after an SMS authentication is sent until it is no longer valid, 300 seconds by default. sms_code_column :: The column in the +sms_codes_table+ containing the currently valid SMS authentication/confirmation code. sms_code_label :: The label for SMS codes. sms_code_param :: The parameter name for SMS codes. sms_codes_primary? :: Whether SMS codes are a primary multifactor authentication method. If not, they cannot be setup unless multifactor authentication has already been setup. sms_codes_table :: The name of the table storing SMS code data. sms_confirm_additional_form_tags :: HTML fragment containing additional form tags when confirming SMS setup. sms_confirm_button :: Text to use for button on the form to confirm SMS setup. sms_confirm_code_length :: The length of SMS confirmation codes, 12 by default, as there is no lockout. sms_confirm_deadline :: The number of seconds before an SMS confirmation code expires (86400 seconds by default). sms_confirm_notice_flash :: The flash notice to show when SMS authentication setup has been confirmed. sms_confirm_page_title :: The page title to use on the form to authenticate via SMS code. sms_confirm_redirect :: Where to redirect after SMS authentication setup has been confirmed. sms_confirm_route :: The route to the SMS setup confirmation action. Defaults to +sms-confirm+. sms_disable_additional_form_tags :: HTML fragment containing additional form tags when disabling SMS authentication. sms_disable_button :: Text to use for button on the form to disable SMS authentication. sms_disable_error_flash :: The flash error to show when disabling SMS authentication fails. sms_disable_link_text :: The text to use for the remove link from the multifactor manage page. sms_disable_notice_flash :: The flash notice to show when SMS authentication has been successfully disabled. sms_disable_page_title :: The page title to use on the form to disable SMS authentication. sms_disable_redirect :: Where to redirect after SMS authentication has been disabled. sms_disable_route :: The route to the SMS authentication disable action. Defaults to +sms-disable+. sms_failure_limit :: The number of failures until SMS authentication is locked out. sms_failures_column :: The column in the +sms_codes_table+ containing the number of SMS authentication failures since the last successful authentication. sms_id_column :: The column in the +sms_codes_table+ containing the account id. sms_invalid_code_error_flash :: The flash error to show when an invalid SMS authentication code is used. sms_invalid_code_message :: The error message to show when an invalid SMS code is used. sms_invalid_confirmation_code_error_flash :: The flash error to show when an invalid SMS confirmation code is used. sms_invalid_phone_message :: The error message to show when an invalid SMS phone number is used. sms_issued_at_column :: The column in the +sms_codes_table+ containing the time the SMS code was issued. sms_lockout_error_flash :: The flash error to show when SMS authentication has been locked out due to repeated failures. sms_lockout_redirect :: Where to redirect after SMS authentication has been locked out. sms_needs_confirmation_notice_flash :: The flash notice to show on SMS authentication pages when SMS authentication setup needs confirmation (uses +sms_needs_confirmation_error_flash+ by default). sms_needs_confirmation_error_flash :: The flash error to show on SMS authentication pages when SMS authentication setup needs confirmation. sms_needs_confirmation_error_status :: The response status to use on SMS authentication pages when SMS authentication setup needs confirmation, 403 by default. sms_needs_confirmation_redirect :: Where to redirect after SMS setup, when confirmation is required. sms_needs_setup_redirect :: Where to redirect if going to an SMS authentication page when SMS authentication has not been setup. sms_not_setup_error_flash :: The flash error to show when on SMS authentication pages when SMS authentication has not yet been setup. sms_phone_column :: The column in the +sms_codes_table+ containing the phone number to which to send SMS messages. sms_phone_input_type :: The input type to use for SMS phone numbers, tel by default. sms_phone_label :: The label for SMS phone numbers. sms_phone_min_length :: The minimum length of phone numbers allowed for SMS authentication, 7 by default. sms_phone_param :: The parameter name for SMS phone numbers. sms_request_additional_form_tags :: HTML fragment containing additional form tags when requesting an SMS authentication code. sms_request_button :: Text to use for button on the form to request an SMS authentication code. sms_request_notice_flash :: The flash notice to show when an SMS authentication code is requested. sms_request_page_title :: The page title to use on the form to request an SMS authentication code. sms_request_redirect :: Where to redirect after requesting an SMS authentication code. sms_request_route :: The route to the SMS authentication code request action. Defaults to +sms-request+. sms_setup_additional_form_tags :: HTML fragment containing additional form tags when setting up SMS authentication. sms_setup_button :: Text to use for button on the form to setup SMS authentication. sms_setup_error_flash :: The flash error to show when setting up SMS authentication fails. sms_setup_link_text :: The text to use for the setup link from the multifactor manage page. sms_setup_page_title :: The page title to use on the form to setup SMS authentication. sms_setup_route :: The route to the SMS authentication setup action. Defaults to +sms-setup+. == Auth Methods after_sms_confirm :: Run arbitrary code after successful SMS authentication confirmation. after_sms_disable :: Run arbitrary code after disabling SMS authentication. after_sms_failure :: Run arbitrary code after SMS authentication failure. after_sms_request :: Run arbitrary code after SMS authentication code request. after_sms_setup :: Run arbitrary code after SMS authentication setup. before_sms_auth :: Run arbitrary code before SMS authentication. before_sms_auth_route :: Run arbitrary code before handling SMS authentication route. before_sms_confirm :: Run arbitrary code before SMS confirmation. before_sms_confirm_route :: Run arbitrary code before handling SMS confirmation route. before_sms_disable :: Run arbitrary code before disabling SMS authentication. before_sms_disable_route :: Run arbitrary code before handling SMS disable route. before_sms_request :: Run arbitrary code before sending SMS code. before_sms_request_route :: Run arbitrary code before handling SMS request route. before_sms_setup :: Run arbitrary code before setting up SMS authentication. before_sms_setup_route :: Run arbitrary code before handling SMS setup route. sms_auth_message(code) :: The SMS message to use for the given authentication code. sms_auth_view :: The HTML to use for the form to authenticate via SMS code. sms_available? :: Whether SMS authentication is ready for use. sms_code_issued_at :: The timestamp the current SMS code was issued at. sms_code_match?(code) :: Whether there is an active SMS authentication code for the current account and the given code matches it. sms_confirm_message(code) :: The SMS message to use for the given confirmation code. sms_confirm_response :: Return a response after successfully confirming SMS code during SMS setup. By default, redirects to +sms_confirm_redirect+. sms_confirm_view :: The HTML to use for the form to authenticate via SMS code. sms_confirmation_match?(code) :: Whether there is an active SMS confirmation code for the current account and the given code matches it. sms_current_auth? :: Whether there is a active SMS authentication code for the current account. sms_disable :: Action to take to disable SMS authentication for the account. sms_disable_response :: Return a response after successfully disabling SMS. By default, redirects to +sms_disable_redirect+. sms_disable_view :: The HTML to use for the form to disable SMS authentication. sms_failures :: The number of SMS authentication failures since the last successfully SMS authentication for this account. sms_locked_out? :: Whether SMS authentication has been locked out for the current account. sms_needs_confirmation? :: Whether SMS authentication has been setup but not confirmed for the current account. sms_needs_confirmation_response :: Return a response after successfully providing SMS number during SMS setup. By default, redirects to +sms_needs_confirmation_redirect+. sms_new_auth_code :: A new SMS authentication code that can be used for the account. sms_new_confirm_code :: A new SMS confirmation code that can be used for the account. sms_normalize_phone(phone) :: A normalized version of the given phone number, by default removing everything except 0-9. sms_record_failure :: Record an SMS authentication failure for the current account. sms_remove_expired_confirm_code :: Remove an expired SMS confirm code, allowing setup of a new sms confirm code. sms_remove_failures :: Reset the SMS authentication failure counter for the current account, used after a successful multifactor authentication. sms_request_response :: Return a response after a successful SMS request during SMS authentication. By default, redirects to +sms_auth_redirect+. sms_request_view :: The HTML to use for the form to request an SMS authentication code. sms_send(phone, message) :: Send the given message to the given phone number via SMS. By default a NotImplementedError is raised, this is the only method that must be overridden. sms_set_code(code) :: Set the SMS authentication code for the current account to the given code. The code can be nil to specify that no SMS authentication code is currently valid. sms_setup :: Setup SMS authentication for the current account. sms_setup? :: Whether SMS authentication has been setup and confirmed for the current account. sms_setup_view :: The HTML to use for the form to setup SMS authentication. sms_valid_phone?(phone) :: Whether the given phone number is a valid phone number. jeremyevans-rodauth-b53f402/doc/two_factor_base.rdoc000066400000000000000000000165411515725514200226320ustar00rootroot00000000000000= Documentation for Two Factor Base Feature The two_factor_base feature implements shared functionality for the other multifactor authentication features. To handle multiple and potentially different multifactor authentication setups per user, this feature implements disambiguation pages for multifactor authentication and manage. If only a single multifactor authentication is available to setup, the manage page will redirect to the appropriate page. Likewise, if only a single multifactor authentication method is available, the authentication page will redirect to the appropriate page. Otherwise, the authentication and manage pages will show links to the available pages. Additionally, there is a separate page for disabling all multifactor authentication methods and reverting to single factor authentication, so users do not have to disable each multifactor authentication method individually. == Auth Value Methods two_factor_already_authenticated_error_flash :: The flash error to show if going to a multifactor authentication page when already multifactor authenticated. two_factor_already_authenticated_error_status :: The response status to use if going to a multifactor authentication page when already multifactor authenticated, 403 by default. two_factor_already_authenticated_redirect :: Where to redirect if going to a multifactor authentication page when already multifactor authenticated. two_factor_auth_notice_flash :: The flash notice to show after a successful multifactor authentication. two_factor_auth_page_title :: The page title to use on the page linking to other multifactor authentication pages. two_factor_auth_redirect :: Where to redirect after a successful multifactor authentication. two_factor_auth_redirect_session_key :: The key in the session hash storing the location to redirect to after successful multifactor authentication. two_factor_auth_required_redirect :: Where to redirect if going to a page requiring multifactor authentication when not multifactor authenticated (the multifactor auth page by default). two_factor_auth_return_to_requested_location? :: Whether to redirect to the originally requested location after successful multifactor authentication when +require_two_factor_authenticated+ was used, false by default. two_factor_auth_route :: The route to the multifactor authentication page. Defaults to +multifactor-auth+. two_factor_disable_additional_form_tags :: HTML fragment containing additional form tags when disabling all multifactor authentication. two_factor_disable_button :: Text to use for button on the form to disable all multifactor authentication. two_factor_disable_error_flash :: The flash error to show if unable to disable all multifactor authentication. two_factor_disable_link_text :: The text to use for the link to disable all multifactor authentication from the multifactor manage page. two_factor_disable_notice_flash :: The flash notice to show after a successfully disabling all multifactor authentication. two_factor_disable_page_title :: The page title to use on the page for disabling all multifactor authentication. two_factor_disable_redirect :: Where to redirect after a successfully disabling all multifactor authentication. two_factor_disable_route :: The route to the page to disable all multifactor authentication. Defaults to +multifactor-disable+. two_factor_manage_page_title :: The page title to use on the page linking to other multifactor setup and remove pages. two_factor_manage_route :: The route to the page to manage multifactor authentication. Defaults to +multifactor-manage+. two_factor_modifications_require_password? :: Whether modifications to multifactor authentication require the inputing the user's password. two_factor_need_authentication_error_flash :: The flash error to show if going to a page that requires multifactor authentication when not authenticated. two_factor_need_authentication_error_status :: The response status to use if going to a page that requires multifactor authentication when not authenticated, 401 by default. two_factor_need_setup_redirect :: Where to redirect if going to a multifactor authentication page when multifactor authentication has not been setup (the multifactor manage page by default). two_factor_not_setup_error_flash :: The flash error to show if going to a multifactor authentication page when multifactor authentication has not been setup. two_factor_not_setup_error_status :: The response status to use if going to a multifactor authentication page when multifactor authentication has not been setup, 403 by default. two_factor_remove_heading :: The HTML to use above the remove links on the multifactor manage page. two_factor_setup_heading :: The HTML to use above the setup links on the multifactor manage page. two_factor_setup_session_key :: The session key used for storing whether multifactor authentication has been setup for the current account. == Auth Methods after_two_factor_authentication :: Any actions to take after successful multifactor authentication. after_two_factor_disable :: Any actions to take after successful disabling of all multifactor authentication. before_two_factor_auth_route :: Run arbitrary code before handling the multifactor auth route. before_two_factor_disable :: Any actions to take before disabling of all multifactor authentication. before_two_factor_disable_route :: Run arbitrary code before handling the multifactor disable route. before_two_factor_manage_route :: Run arbitrary code before handling the multifactor manage route. two_factor_auth_links :: An array of entries for links to show on the multifactor auth page. Each entry is an array of three elements, sort order (integer), link href, and link text. two_factor_auth_response :: Return a response after successful multifactor authentication. By default, redirects to +two_factor_auth_redirect+ (or the requested location if +two_factor_auth_return_to_requested_location?+ is true). two_factor_auth_view :: The HTML to use for the page linking to other multifactor authentication pages. two_factor_authenticated? :: Whether the current session has already been multifactor authenticated. two_factor_disable_response :: Return a response after successfully disabling multifactor authentication. By default, redirects to +two_factor_disable_redirect+. two_factor_disable_view :: The HTML to use for the page for disabling all multifactor authentication. two_factor_manage_view :: The HTML to use for the page linking to other multifactor setup and remove pages. two_factor_remove :: Any action to take to remove multifactor authentication, called when closing accounts. two_factor_remove_auth_failures :: Any action to take to remove multifactor authentication failures, called after a successful multifactor authentication. two_factor_remove_links :: An array of entries for remove links to show on the multifactor manage page. Each entry is an array of three elements, sort order (integer), link href, and link text. two_factor_remove_session :: What actions to take to remove multifactor authentication status from the session, called when disabling multifactor authentication when authenticated using the factor being removed. two_factor_setup_links :: An array of entries for setup links to show on the multifactor manage page. Each entry is an array of three elements, sort order (integer), link href, and link text. two_factor_update_session(type) :: How to update the session to reflect a successful multifactor authentication. jeremyevans-rodauth-b53f402/doc/update_password_hash.rdoc000066400000000000000000000005731515725514200236760ustar00rootroot00000000000000= Documentation for Update Password Hash Feature The update password hash feature updates the hash for the password whenever the hash cost changes. For example, if you have a cost of 8, and later increase the cost to 10, anytime the user authenticates correctly with their password, their password hash will change from one that uses a cost of 8 to one that uses a cost of 10. jeremyevans-rodauth-b53f402/doc/verify_account.rdoc000066400000000000000000000142501515725514200225040ustar00rootroot00000000000000= Documentation for Verify Account Feature The verify account feature implements account verification after account creation. After account creation, users are sent an email containing a link to verify the account. Users cannot login to the account until after verifying the account. Depends on the login and create account features. == Auth Value Methods attempt_to_create_unverified_account_error_flash :: The flash error message to show when attempting to create an account awaiting verification. attempt_to_login_to_unverified_account_error_flash :: The flash error message to show when attempting to login to an account awaiting verification. no_matching_verify_account_key_error_flash :: The flash error message to show when an invalid verify account key is used. resend_verify_account_page_title :: The page title to use on page requesting resending the verify account email. verify_account_additional_form_tags :: HTML fragment containing additional form tags to use on the verify account form. verify_account_autologin? :: Whether to autologin the user after successful account verification, true by default. verify_account_button :: The text to use for the verify account button. verify_account_email_last_sent_column :: The email last sent column in the +verify_account_table+. Set to nil to always send a verify account email when requested. verify_account_email_recently_sent_error_flash :: The flash error to show if not sending verify account email because one has been sent recently. verify_account_email_recently_sent_redirect :: Where to redirect if not sending verify account email because one has been sent recently. verify_account_email_sent_notice_flash :: The flash notice to set after sending the verify account email. verify_account_email_sent_redirect :: Where to redirect after sending the verify account email. verify_account_email_subject :: The subject to use for the verify account email. verify_account_error_flash :: The flash error to show if no matching key is submitted when verifying an account. verify_account_id_column :: The id column in the +verify_account_table+, should be a foreign key referencing the accounts table. verify_account_key_column :: The verify account key/token column in the +verify_account_table+. verify_account_key_param :: The parameter name to use for the verify account key. verify_account_notice_flash :: The flash notice to show after verifying the account. verify_account_page_title :: The page title to use on the verify account form. verify_account_redirect :: Where to redirect after verifying the account. verify_account_resend_additional_form_tags :: HTML fragment containing additional form tags to use on the page requesting resending the verify account email. verify_account_resend_button :: The text to use for the verify account resend button. verify_account_resend_error_flash :: The flash error to show if unable to resend a verify account email. verify_account_resend_explanatory_text :: The text to display above the button to resend the verify account email. verify_account_resend_link_text :: The text to use for a link to the page to request the account verification email be resent. verify_account_resend_route :: The route to the verify account resend action. Defaults to +verify-account-resend+. verify_account_route :: The route to the verify account action. Defaults to +verify-account+. verify_account_session_key :: The key in the session to hold the verify account key temporarily. verify_account_set_password? :: Whether to ask for a password to be set on the verify account form. True by default. If set to false, will ask for password when creating the account instead of when verifying. verify_account_skip_resend_email_within :: The number of seconds before sending another verify account email, if +verify_account_email_last_sent_column+ is set. verify_account_table :: The name of the verify account keys table. == Auth Methods account_from_verify_account_key(key) :: Retrieve the account using the given verify account key, or return nil if no account matches. after_verify_account :: Run arbitrary code after verifying the account. after_verify_account_email_resend :: Run arbitrary code after resending a verify account email. allow_resending_verify_account_email? :: Whether to allow sending the verify account email for the account, true by default only if the account has not been verified. before_verify_account :: Run arbitrary code before verifying the account. before_verify_account_email_resend :: Run arbitrary code before resending a verify account email. before_verify_account_resend_route :: Run arbitrary code before handling a verify account resend route. before_verify_account_route :: Run arbitrary code before handling a verify account route. create_verify_account_email :: A Mail::Message for the verify account email. create_verify_account_key :: Add the verify account key data to the database. get_verify_account_email_last_sent :: Get the last time a verify account email is sent, or nil if there is no last sent time. get_verify_account_key(id) :: Get the verify account key for the given account id from the database. remove_verify_account_key :: Remove the verify account key for the current account, run after successful account verification. resend_verify_account_view :: The HTML to use for page requesting resending the verify account email. send_verify_account_email :: Send the verify account email. set_verify_account_email_last_sent :: Set the last time a verify account email is sent. verify_account :: Verify the account by changing the status from unverified to open. verify_account_email_body :: The body to use for the verify account email. verify_account_email_link :: The link to the verify account form in the verify account email. verify_account_email_sent_response :: Return a response after successfully sending an verify account email. By default, redirects to +verify_account_email_sent_redirect+. verify_account_key_insert_hash :: The hash to insert into the +verify_account_table+. verify_account_key_value :: The value of the verify account key. verify_account_response :: Return a response after successfully verifying an account. By default, redirects to +verify_account_redirect+. verify_account_view :: The HTML to use for the verify account form. jeremyevans-rodauth-b53f402/doc/verify_account_grace_period.rdoc000066400000000000000000000021471515725514200252110ustar00rootroot00000000000000= Documentation for Verify Account Grace Period Feature The verify account grace period feature allows users to login for a given period of time (1 day by default) before their account is verified. Depends on the verify account feature. This switches the +verify_account_set_password?+ to false so that user can login with a password during the grace period. == Auth Value Methods unverified_account_session_key :: The session key set if the logged in account has not been unverified. unverified_change_login_error_flash :: The flash error to show when an unverified accounts accesses a change login route. unverified_change_login_redirect :: Where to redirect when an unverified accounts accesses a change login route. verification_requested_at_column :: The column in the +verify_account_table+ table that holds the verification requested timestamp. verify_account_grace_period :: The amount of seconds after an account creation that a user will be able to login without verifying (86400 by default). == Auth Methods account_in_unverified_grace_period? :: Whether the current account is in an unverified grace period. jeremyevans-rodauth-b53f402/doc/verify_login_change.rdoc000066400000000000000000000121561515725514200234700ustar00rootroot00000000000000= Documentation for Verify Login Change Feature The verify login change feature implements verification of login changes. With this feature, login changes do not take effect until after the user has verified the new login. Until the new login has been verified, the old login continues to work. Any time you use the verify account and change login features together, you should probably use this, otherwise it is trivial for users to work around account verification by creating an account with an email address they control, and the changing the login to an email address they don't control. Depends on the change login and email base features. == Auth Value Methods no_matching_verify_login_change_key_error_flash :: The flash error message to show when an invalid verify login change key is used. change_login_needs_verification_notice_flash :: The flash notice to show after changing a login when using this feature, if +change_login_notice_flash+ is not overridden. verify_login_change_additional_form_tags :: HTML fragment containing additional form tags to use on the verify login change form. verify_login_change_autologin? :: Whether to autologin the user after successful login change verification, false by default. verify_login_change_button :: The text to use for the verify login change button. verify_login_change_deadline_column :: The column name in the +verify_login_change_table+ storing the deadline after which the token will be ignored. verify_login_change_deadline_interval :: The amount of time for which to allow users to verify login changes, 1 day by default. verify_login_change_duplicate_account_error_flash :: The flash error message to show when attempting to verify a login change when the login is already taken. verify_login_change_duplicate_account_redirect :: Where to redirect if not changing a login during verification because the new login is already taken. verify_login_change_email_subject :: The subject to use for the verify login change email. verify_login_change_error_flash :: The flash error to show if no matching key is submitted when verifying login change. verify_login_change_id_column :: The id column in the +verify_login_change_table+, should be a foreign key referencing the accounts table. verify_login_change_key_column :: The verify login change key/token column in the +verify_login_change_table+. verify_login_change_key_param :: The parameter name to use for the verify login change key. verify_login_change_login_column :: The login column in the +verify_login_change_table+, containing the new login. verify_login_change_notice_flash :: The flash notice to show after verifying the login change. verify_login_change_page_title :: The page title to use on the verify login change form. verify_login_change_redirect :: Where to redirect after verifying the login change. verify_login_change_route :: The route to the verify login change action. Defaults to +verify-login-change+. verify_login_change_session_key :: The key in the session to hold the verify login change key temporarily. verify_login_change_table :: The name of the verify login change keys table. == Auth Methods account_from_verify_login_change_key(key) :: Retrieve the account using the given verify account key, or return nil if no account matches. Should also override verify_login_change_new_login if overriding this method. after_verify_login_change :: Run arbitrary code after verifying the login change. after_verify_login_change_email :: Run arbitrary code after sending verify login change email. before_verify_login_change :: Run arbitrary code before verifying the login change. before_verify_login_change_email :: Run arbitrary code before sending verify login change email. before_verify_login_change_route :: Run arbitrary code before handling a verify login change route. create_verify_login_change_email(login) :: A Mail::Message for the verify login change email. create_verify_login_change_key(login) :: Add the verify login change key data to the database. get_verify_login_change_login_and_key(id) :: Get the verify login change login and key for the given account id from the database. remove_verify_login_change_key :: Remove the verify login change key for the current account, run after successful login change verification. send_verify_login_change_email(login) :: Send the verify login change email. verify_login_change :: Change the login for the given account to the new login. verify_login_change_email_body :: The body to use for the verify login change email. verify_login_change_email_link :: The link to the verify login change form in the verify login change email. verify_login_change_key_insert_hash(login) :: The hash to insert into the +verify_login_change_table+. verify_login_change_key_value :: The value of the verify login change key. verify_login_change_new_login :: The new login to use when the login change is verified. verify_login_change_old_login :: The old login to display in the verify login change email. verify_login_change_response :: Return a response after successfully verifying a login change. By default, redirects to +verify_login_change_redirect+. verify_login_change_view :: The HTML to use for the verify login change form. jeremyevans-rodauth-b53f402/doc/webauthn.rdoc000066400000000000000000000260331515725514200213030ustar00rootroot00000000000000= Documentation for WebAuthn Feature The webauthn feature implements multifactor authentication via WebAuthn. It supports registering WebAuthn authenticators, using them for multifactor authentication, and removing WebAuthn authenticators. This feature supports multiple WebAuthn authenticators per user, and users are encouraged to have multiple WebAuthn authenticators so that they have a backup if one is not available. WebAuthn authentication requires javascript to work in browsers, for the browser to communicate with the authenticator. This feature offers routes that return the appropriate javascript. However, the javascript works by setting a hidden form field and using normal form submission. This allows testing the feature without using javascript. See Rodauth's tests for how testing without javascript works. The webauthn feature requires the webauthn gem. == Auth Value Methods authenticated_webauthn_id_session_key :: The session key used for storing which WebAuthn ID was used during authentication. webauthn_attestation :: The value of the WebAuthn attestation option when registering a new WebAuthn authenticator. webauthn_auth_additional_form_tags :: HTML fragment containing additional form tags when authenticating via WebAuthn. webauthn_auth_button :: Text to use for button on the form to authenticate via WebAuthn. webauthn_auth_challenge_hmac_param :: The parameter name for the HMAC of the WebAuthn challenge during authentication. webauthn_auth_challenge_param :: The parameter name for the WebAuthn challenge during authentication. webauthn_auth_error_flash :: The flash error to show if unable to authenticate via WebAuthn. webauthn_auth_js :: The javascript code to execute on the page to authenticate via WebAuthn. webauthn_auth_js_route :: The route to the webauthn auth javascript file. webauthn_auth_link_text :: The text to use for the link from the multifactor auth page. webauthn_auth_page_title :: The page title to use on the page for authenticating via WebAuthn. webauthn_auth_param :: The parameter name for the WebAuthn authentication data. webauthn_auth_route :: The route to the webauthn auth action. webauthn_auth_timeout :: The number of milliseconds to wait when authenticating using a WebAuthn authenticator. webauthn_authenticator_selection :: The value of the WebAuthn authenticatorSelection option when registering a new WebAuthn authenticator. webauthn_duplicate_webauthn_id_message :: The error message to when there is an attempt to insert a duplicate WebAuthn authenticator. webauthn_extensions :: The value of the WebAuthn extensions option when registering a new WebAuthn authenticator or authenticating via WebAuthn. webauthn_invalid_auth_param_message :: The error message to show when invalid or missing WebAuthn authentication data is provided. webauthn_invalid_remove_param_message :: The error message to show when invalid WebAuthn ID is provided when removing a WebAuthn authenticator. webauthn_invalid_setup_param_message :: The error message to show when invalid or missing WebAuthn registration data is provided. webauthn_invalid_sign_count_message :: The error message to when there is an attempt to authenticate with WebAuthn authenticator with an invalid sign count. webauthn_js_host :: The protocol and domain if using a separate host for the WebAuthn setup and auth javascript files. webauthn_keys_account_id_column :: The column in the +webauthn_keys_table+ containing the account id. webauthn_keys_last_use_column :: The column in the +webauthn_keys_table+ containing the last time the WebAuthn credential was used. webauthn_keys_public_key_column :: The column in the +webauthn_keys_table+ containing the public key for the WebAuthn credential. webauthn_keys_sign_count_column :: The column in the +webauthn_keys_table+ containing the sign count for the WebAuthn credential. webauthn_keys_table :: The table name containing the WebAuthn public keys. webauthn_keys_webauthn_id_column :: The column in the +webauthn_keys_table+ containing the WebAuthn ID for the WebAuthn credential. webauthn_not_setup_error_flash :: The flash error to show if going to the WebAuthn authentication page without having registered a WebAuthn authenticator. webauthn_not_setup_error_status :: The status code to use if going to the WebAuthn authentication page without having registered a WebAuthn authenticator. webauthn_origin :: The origin to use when verifying a WebAuthn authenticator. webauthn_remove_additional_form_tags :: HTML fragment containing additional form tags when removing an existing WebAuthn authenticator. webauthn_remove_button :: Text to use for button on the form to remove an existing WebAuthn authenticator. webauthn_remove_error_flash :: The flash error to show if unable to remove an existing WebAuthn authenticator. webauthn_remove_link_text :: The text to use for the remove link from the multifactor manage page. webauthn_remove_notice_flash :: The flash notice to show after removing an existing WebAuthn authenticator. webauthn_remove_page_title :: The page title to use on the page for removing an existing WebAuthn authenticator. webauthn_remove_param :: The parameter name for the WebAuthn ID to remove. webauthn_remove_redirect :: Where to redirect after successfully removing an existing WebAuthn authenticator. webauthn_remove_route :: The route to the webauthn remove action. webauthn_rp_id :: The relying party ID to use when registering a WebAuthn authenticator or authenticating via WebAuthn. webauthn_rp_name :: The relying party name to use when registering a WebAuthn authenticator. webauthn_setup_additional_form_tags :: HTML fragment containing additional form tags when registering a new WebAuthn authenticator. webauthn_setup_button :: Text to use for button on the form to register a new WebAuthn authenticator. webauthn_setup_challenge_hmac_param :: The parameter name for the HMAC of the WebAuthn challenge during registration. webauthn_setup_challenge_param :: The parameter name for the WebAuthn challenge during registration. webauthn_setup_error_flash :: The flash error to show if unable to register a new WebAuthn authenticator. webauthn_setup_js :: The javascript code to execute on the page to register a new WebAuthn credential. webauthn_setup_js_route :: The route to the webauthn setup javascript file. webauthn_setup_link_text :: The text to use for the setup link from the multifactor manage page. webauthn_setup_notice_flash :: The flash notice to show after registering a new WebAuthn authenticator. webauthn_setup_page_title :: The page title to use on the page for registering a new WebAuthn authenticator. webauthn_setup_param :: The parameter name for the WebAuthn registration data. webauthn_setup_redirect :: Where to redirect after successfully registering a new WebAuthn authenticator. webauthn_setup_timeout :: The number of milliseconds to wait when registering a new WebAuthn authenticator. webauthn_setup_route :: The route to the webauthn setup action. webauthn_user_ids_account_id_column :: The column in the +webauthn_user_ids_table+ containing the account id. webauthn_user_ids_table :: The table name containing the WebAuthn user IDs. webauthn_user_ids_webauthn_id_column :: The column in the +webauthn_user_ids_table+ containing the accounts WebAuthn user ID. webauthn_user_verification :: The value of the WebAuthn userVerification option when registering a new WebAuthn authenticator. == Auth Methods account_webauthn_ids :: An array of WebAuthn IDs for registered WebAuthn credentials for the current account. account_webauthn_usage :: A hash mapping WebAuthn IDs to the time of their last use for registered WebAuthn credentials for the current account. account_webauthn_user_id :: The WebAuthn User ID for the current account. add_webauthn_credential(webauthn_credential) :: Register the given WebAuthn credential to current account. after_webauthn_auth_failure :: Any actions to take after a WebAuthn authentication failure. after_webauthn_remove :: Any actions to take after removing an existing WebAuthn authenticator. after_webauthn_setup :: Any actions to take after registering a new WebAuthn authenticator. authenticated_webauthn_id :: The WebAuthn ID for the credential used to authenticate via WebAuthn for the current session. before_webauthn_auth :: Any actions to take before authenticating via WebAuthn. before_webauthn_auth_js_route :: Run arbitrary code before handling a webauthn auth javascript route. before_webauthn_auth_route :: Run arbitrary code before handling a webauthn auth route. before_webauthn_remove :: Any actions to take before removing an existing WebAuthn authenticator. before_webauthn_remove_route :: Run arbitrary code before handling a webauthn remove route. before_webauthn_setup :: Any actions to take before registering a new WebAuthn authenticator. before_webauthn_setup_js_route :: Run arbitrary code before handling a webauthn setup javascript route. before_webauthn_setup_route :: Run arbitrary code before handling a webauthn setup route. handle_webauthn_sign_count_verification_error :: What actions to take if there is an invalid sign count when authenticating. The default results in an error, but overriding without calling super will result in successful WebAuthn authentication. new_webauthn_credential :: WebAuthn credential options to provide to the client during WebAuthn registration. remove_all_webauthn_keys_and_user_ids :: Remove all WebAuthn credentials and the WebAuthn user ID from the current account. remove_webauthn_key(webauthn_id) :: Remove the WebAuthn credential with the given WebAuthn ID from the current account. valid_new_webauthn_credential?(webauthn_credential) :: Check wheck the WebAuthn credential provided by the client during registration is valid. valid_webauthn_credential_auth?(webauthn_credential) :: Check wheck the WebAuthn credential provided by the client during authentication is valid. webauthn_auth_js_path :: The path to the WebAuthn authentication javascript. webauthn_auth_view :: The HTML to use for the page for authenticating via WebAuthn. webauthn_credential_options_for_get :: WebAuthn credential options to provide to the client during WebAuthn authentication. webauthn_key_insert_hash(webauthn_credential) :: The hash to insert into the +webauthn_keys_table+. webauthn_remove_authenticated_session :: Remove the authenticated WebAuthn ID, used when removing the WebAuthn credential with the ID after authenticating with it. webauthn_remove_response :: Return a response after successfully removing a WebAuthn authenticator. By default, redirects to +webauthn_remove_redirect+. webauthn_remove_view :: The HTML to use for the page for removing an existing WebAuthn authenticator. webauthn_setup_js_path :: The path to the WebAuthn registration javascript. webauthn_setup_response :: Return a response after successfully setting up a WebAuthn authenticator. By default, redirects to +webauthn_setup_redirect+. webauthn_setup_view :: The HTML to use for the page for registering a new WebAuthn authenticator. webauthn_update_session(webauthn_id) :: Set the authenticated WebAuthn ID after authenticating via WebAuthn. webauthn_user_name :: The user name to use when registering a new WebAuthn credential, the user's email by default. jeremyevans-rodauth-b53f402/doc/webauthn_autofill.rdoc000066400000000000000000000016531515725514200232030ustar00rootroot00000000000000= Documentation for WebAuthn Autofill Feature The webauthn_autofill feature enables autofill UI (aka "conditional mediation") for WebAuthn credentials, logging the user in on selection. It depends on the webauthn_login feature. This feature allows generating WebAuthn credential options and submitting a WebAuthn login request without providing a login, which can be used independently from the autofill UI. == Auth Value Methods webauthn_autofill? :: Whether to activate the autofill UI on the login page. webauthn_autofill_js :: The javascript code to execute on the login page to enable autofill UI. webauthn_autofill_js_route :: The route to the webauthn autofill javascript file. webauthn_invalid_webauthn_id_message :: The error message to show when provided WebAuthn ID wasn't found in the database. == Auth Methods before_webauthn_autofill_js_route :: Run arbitrary code before handling a webauthn autofill javascript route. jeremyevans-rodauth-b53f402/doc/webauthn_login.rdoc000066400000000000000000000020141515725514200224640ustar00rootroot00000000000000= Documentation for WebAuthn Login Feature The webauthn_login feature implements passwordless authentication via WebAuthn. It depends on the login and webauthn features. == Auth Value Methods webauthn_login_user_verification_additional_factor? :: Whether passwordless login via WebAuthn should consider user verification as 2nd factor when using multifactor authentication, false by default. Setting this to true means that the app trusts the user verification done by the authenticator is strong enough to be considered an additional factor. webauthn_login_error_flash :: The flash error to show if there is a failure during passwordless login via WebAuthn. webauthn_login_failure_redirect :: Whether to redirect if there is a failure during passwordless login via WebAuthn. webauthn_login_route :: The route to the webauthn login action. == Auth Methods before_webauthn_login :: Any actions to take before passwordless login via WebAuthn. before_webauthn_login_route :: Run arbitrary code before handling a webauthn login route. jeremyevans-rodauth-b53f402/doc/webauthn_modify_email.rdoc000066400000000000000000000027331515725514200240220ustar00rootroot00000000000000= Documentation for WebAuthn Modify Email Feature The webauthn_modify_email feature emails users when a WebAuthn authenticator is added to or removed from their account. The webauthn_modify_email feature depends on the webauthn and email_base features. == Auth Value Methods webauthn_authenticator_added_email_body :: Body to use for the email notifying user that a WebAuthn authenticator has been added to their account. webauthn_authenticator_added_email_subject :: Subject to use for the email notifying user that a WebAuthn authenticator has been added to their account. webauthn_authenticator_removed_email_body :: Body to use for the email notifying user that a WebAuthn authenticator has been removed from their account. webauthn_authenticator_removed_email_subject :: Subject to use for the email notifying user that a WebAuthn authenticator has been removed from their account. == Auth Methods create_webauthn_authenticator_added_email :: A Mail::Message for the email notifying user that a WebAuthn authenticator has been added to their account. create_webauthn_authenticator_removed_email :: A Mail::Message for the email notifying user that a WebAuthn authenticator has been removed from their account. send_webauthn_authenticator_added_email :: Send the email notifying user that a WebAuthn authenticator has been added to their account. send_webauthn_authenticator_removed_email :: Send the email notifying user that a WebAuthn authenticator has been removed from their account. jeremyevans-rodauth-b53f402/doc/webauthn_verify_account.rdoc000066400000000000000000000007411515725514200244010ustar00rootroot00000000000000= Documentation for WebAuthn Verify Account Feature The webauthn_verify_account feature implements setting up an WebAuthn authenticator during the account verification process, and making such setup a requirement for account verification. By default, it disables asking for a password during account creation and verification, allowing for completely passwordless designs, where the only authentication option is WebAuthn. It depends on the verify_account and webauthn features. jeremyevans-rodauth-b53f402/javascript/000077500000000000000000000000001515725514200202125ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/javascript/webauthn_auth.js000066400000000000000000000035411515725514200234110ustar00rootroot00000000000000(function() { var pack = function(v) { return btoa(String.fromCharCode.apply(null, new Uint8Array(v))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); }; var unpack = function(v) { return Uint8Array.from(atob(v.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); }; var element = document.getElementById('webauthn-auth-form'); var f = function(e) { //console.log(e); e.preventDefault(); if (navigator.credentials) { var opts = JSON.parse(element.getAttribute("data-credential-options")); opts.challenge = unpack(opts.challenge); opts.allowCredentials.forEach(function(cred) { cred.id = unpack(cred.id); }); //console.log(opts); navigator.credentials.get({publicKey: opts}). then(function(cred){ //console.log(cred); //window.cred = cred var rawId = pack(cred.rawId); var authValue = { type: cred.type, id: rawId, rawId: rawId, response: { authenticatorData: pack(cred.response.authenticatorData), clientDataJSON: pack(cred.response.clientDataJSON), signature: pack(cred.response.signature) } }; if (cred.response.userHandle) { authValue.response.userHandle = pack(cred.response.userHandle); } document.getElementById('webauthn-auth').value = JSON.stringify(authValue); element.removeEventListener("submit", f); element.submit(); }). catch(function(e){document.getElementById('webauthn-auth-button').innerHTML = "Error authenticating using WebAuthn: " + e}); } else { document.getElementById('webauthn-auth-button').innerHTML = "WebAuthn not supported by browser, or browser has disabled it on this page"; } }; element.addEventListener("submit", f); })(); jeremyevans-rodauth-b53f402/javascript/webauthn_autofill.js000066400000000000000000000026701515725514200242710ustar00rootroot00000000000000(function() { var pack = function(v) { return btoa(String.fromCharCode.apply(null, new Uint8Array(v))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); }; var unpack = function(v) { return Uint8Array.from(atob(v.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); }; var element = document.getElementById('webauthn-login-form'); if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) return; PublicKeyCredential.isConditionalMediationAvailable().then(function(available) { if (!available) return; var opts = JSON.parse(element.getAttribute("data-credential-options")); opts.challenge = unpack(opts.challenge); opts.allowCredentials.forEach(function(cred) { cred.id = unpack(cred.id); }); navigator.credentials.get({mediation: "conditional", publicKey: opts}).then(function(cred) { var rawId = pack(cred.rawId); var authValue = { type: cred.type, id: rawId, rawId: rawId, response: { authenticatorData: pack(cred.response.authenticatorData), clientDataJSON: pack(cred.response.clientDataJSON), signature: pack(cred.response.signature) } }; if (cred.response.userHandle) { authValue.response.userHandle = pack(cred.response.userHandle); } document.getElementById('webauthn-auth').value = JSON.stringify(authValue); element.submit(); }); }); })(); jeremyevans-rodauth-b53f402/javascript/webauthn_setup.js000066400000000000000000000032721515725514200236110ustar00rootroot00000000000000(function() { var pack = function(v) { return btoa(String.fromCharCode.apply(null, new Uint8Array(v))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); }; var unpack = function(v) { return Uint8Array.from(atob(v.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); }; var element = document.getElementById('webauthn-setup-form'); var f = function(e) { //console.log(e); e.preventDefault(); if (navigator.credentials) { var opts = JSON.parse(element.getAttribute("data-credential-options")); opts.challenge = unpack(opts.challenge); opts.user.id = unpack(opts.user.id); opts.excludeCredentials.forEach(function(cred) { cred.id = unpack(cred.id); }); //console.log(opts); navigator.credentials.create({publicKey: opts}). then(function(cred){ //console.log(cred); //window.cred = cred var rawId = pack(cred.rawId); document.getElementById('webauthn-setup').value = JSON.stringify({ type: cred.type, id: rawId, rawId: rawId, response: { attestationObject: pack(cred.response.attestationObject), clientDataJSON: pack(cred.response.clientDataJSON) } }); element.removeEventListener("submit", f); element.submit(); }). catch(function(e){document.getElementById('webauthn-setup-button').innerHTML = "Error creating public key in authenticator: " + e}); } else { document.getElementById('webauthn-setup-button').innerHTML = "WebAuthn not supported by browser, or browser has disabled it on this page"; } }; element.addEventListener("submit", f); })(); jeremyevans-rodauth-b53f402/lib/000077500000000000000000000000001515725514200166125ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/lib/roda/000077500000000000000000000000001515725514200175375ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/lib/roda/plugins/000077500000000000000000000000001515725514200212205ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/lib/roda/plugins/rodauth.rb000066400000000000000000000001661515725514200232160ustar00rootroot00000000000000# frozen-string-literal: true require_relative '../../rodauth' Roda::RodaPlugins.register_plugin(:rodauth, Rodauth) jeremyevans-rodauth-b53f402/lib/rodauth.rb000066400000000000000000000276411515725514200206170ustar00rootroot00000000000000# frozen-string-literal: true require 'securerandom' module Rodauth class ConfigurationError < StandardError; end def self.lib(opts={}, &block) require 'roda' c = Class.new(Roda) c.plugin(:rodauth, opts) do enable :internal_request instance_exec(&block) end c.freeze c.rodauth end def self.load_dependencies(app, opts={}, &_) json_opt = opts.fetch(:json, app.opts[:rodauth_json]) if json_opt app.plugin :json app.plugin :json_parser end unless json_opt == :only unless opts[:render] == false require 'tilt/string' app.plugin :render end case opts.fetch(:csrf, app.opts[:rodauth_csrf]) when false # nothing when :rack_csrf # :nocov: app.plugin :csrf # :nocov: else app.plugin :route_csrf end app.plugin :flash unless opts[:flash] == false app.plugin :h end end def self.configure(app, opts={}, &block) json_opt = app.opts[:rodauth_json] = opts.fetch(:json, app.opts[:rodauth_json]) csrf = app.opts[:rodauth_csrf] = opts.fetch(:csrf, app.opts[:rodauth_csrf]) app.opts[:rodauth_route_csrf] = case csrf when false, :rack_csrf false else json_opt != :only end auth_class = (app.opts[:rodauths] ||= {})[opts[:name]] ||= opts[:auth_class] || Class.new(Auth) if !auth_class.roda_class auth_class.roda_class = app elsif auth_class.roda_class != app auth_class = app.opts[:rodauths][opts[:name]] = Class.new(auth_class) auth_class.roda_class = app end auth_class.class_eval{@configuration_name = opts[:name] unless defined?(@configuration_name)} auth_class.configure(&block) if block auth_class.allocate.post_configure if auth_class.method_defined?(:post_configure) end FEATURES = {} class FeatureConfiguration < Module def def_configuration_methods(feature) private_methods = feature.private_instance_methods.map(&:to_sym) priv = proc{|m| private_methods.include?(m)} feature.auth_methods.each{|m| def_auth_method(m, priv[m])} feature.auth_value_methods.each{|m| def_auth_value_method(m, priv[m])} feature.auth_private_methods.each{|m| def_auth_private_method(m)} end private def def_auth_method(meth, priv) define_method(meth) do |&block| @auth.send(:define_method, meth, &block) @auth.send(:private, meth) if priv @auth.send(:alias_method, meth, meth) end end def def_auth_private_method(meth) umeth = :"_#{meth}" define_method(meth) do |&block| @auth.send(:define_method, umeth, &block) @auth.send(:private, umeth) @auth.send(:alias_method, umeth, umeth) end end def def_auth_value_method(meth, priv) define_method(meth) do |v=nil, &block| block ||= proc{v} @auth.send(:define_method, meth, &block) @auth.send(:private, meth) if priv @auth.send(:alias_method, meth, meth) end end end class Feature < Module [:auth, :auth_value, :auth_private].each do |meth| name = :"#{meth}_methods" define_method(name) do |*v| iv = :"@#{name}" existing = instance_variable_get(iv) || [] if v.empty? existing else instance_variable_set(iv, existing + v) end end end attr_accessor :feature_name attr_accessor :dependencies attr_accessor :routes attr_accessor :configuration attr_reader :internal_request_methods def route(name=feature_name, default=name.to_s.tr('_', '-'), &block) route_meth = :"#{name}_route" auth_value_method route_meth, default define_method(:"#{name}_path"){|opts={}| route_path(send(route_meth), opts) if send(route_meth)} define_method(:"#{name}_url"){|opts={}| route_url(send(route_meth), opts) if send(route_meth)} handle_meth = :"handle_#{name}" internal_handle_meth = :"_#{handle_meth}" before route_meth define_method(internal_handle_meth, &block) define_method(handle_meth) do request.is send(route_meth) do @current_route = name check_csrf if check_csrf? _around_rodauth do before_rodauth send(internal_handle_meth, request) end end end routes << handle_meth end def self.define(name, constant=nil, &block) feature = new feature.dependencies = [] feature.routes = [] feature.feature_name = name configuration = feature.configuration = FeatureConfiguration.new feature.module_eval(&block) configuration.def_configuration_methods(feature) # :nocov: if constant # :nocov: Rodauth.const_set(constant, feature) Rodauth::FeatureConfiguration.const_set(constant, configuration) end FEATURES[name] = feature end def internal_request_method(name=feature_name) (@internal_request_methods ||= []) << name end def configuration_module_eval(&block) configuration.module_eval(&block) end if RUBY_VERSION >= '2.5' DEPRECATED_ARGS = [{:uplevel=>1}] else # :nocov: DEPRECATED_ARGS = [] # :nocov: end def def_deprecated_alias(new, old) configuration_module_eval do define_method(old) do |*a, &block| warn("Deprecated #{old} method used during configuration, switch to using #{new}", *DEPRECATED_ARGS) send(new, *a, &block) end end define_method(old) do warn("Deprecated #{old} method called at runtime, switch to using #{new}", *DEPRECATED_ARGS) send(new) end end DEFAULT_REDIRECT_BLOCK = proc{default_redirect} def redirect(name=feature_name, &block) meth = :"#{name}_redirect" block ||= DEFAULT_REDIRECT_BLOCK define_method(meth, &block) auth_value_methods meth end def view(page, title, name=feature_name) meth = :"#{name}_view" title_meth = :"#{name}_page_title" translatable_method(title_meth, title) define_method(meth) do view(page, send(title_meth)) end auth_methods meth end def response(name=feature_name) meth = :"#{name}_response" overridable_meth = :"_#{meth}" notice_flash_meth = :"#{name}_notice_flash" redirect_meth = :"#{name}_redirect" define_method(overridable_meth) do set_notice_flash send(notice_flash_meth) redirect send(redirect_meth) end define_method(meth) do require_response(overridable_meth) end private overridable_meth, meth auth_private_methods meth end def loaded_templates(v) define_method(:loaded_templates) do super().concat(v) end private :loaded_templates end def depends(*deps) dependencies.concat(deps) end %w'after before'.each do |hook| define_method(hook) do |name=feature_name| meth = "#{hook}_#{name}" class_eval("def #{meth}; super if defined?(super); _#{meth}; hook_action(:#{hook}, :#{name}); nil end", __FILE__, __LINE__) class_eval("def _#{meth}; nil end", __FILE__, __LINE__) private meth, :"_#{meth}" auth_private_methods(meth) end end def email(type, subject, opts = {}) subject_method = :"#{type}_email_subject" body_method = :"#{type}_email_body" create_method = :"create_#{type}_email" send_method = :"send_#{type}_email" translatable_method subject_method, subject auth_methods create_method, send_method body_template = "#{type.to_s.tr('_', '-')}-email" if opts[:translatable] auth_value_methods body_method define_method(body_method){translate(body_method, render(body_template))} else auth_methods body_method define_method(body_method){render(body_template)} end define_method(create_method) do create_email(send(subject_method), send(body_method)) end define_method(send_method) do send_email(send(create_method)) end end def additional_form_tags(name=feature_name) auth_value_method(:"#{name}_additional_form_tags", nil) end def session_key(meth, value) define_method(meth){convert_session_key(value)} auth_value_methods(meth) end def flash_key(meth, value) define_method(meth){normalize_session_or_flash_key(value)} auth_value_methods(meth) end def auth_value_method(meth, value) define_method(meth){value} auth_value_methods(meth) end def translatable_method(meth, value) define_method(meth){translate(meth, value)} auth_value_methods(meth) end def auth_cached_method(meth, iv=:"@#{meth}") umeth = :"_#{meth}" define_method(meth) do if instance_variable_defined?(iv) instance_variable_get(iv) else instance_variable_set(iv, send(umeth)) end end alias_method(meth, meth) auth_private_methods(meth) end [:notice_flash, :error_flash, :button].each do |meth| define_method(meth) do |v, name=feature_name| translatable_method(:"#{name}_#{meth}", v) end end end class Configuration attr_reader :auth def initialize(auth, &block) @auth = auth # :nocov: # Only for backwards compatibility # RODAUTH3: Remove apply(&block) if block # :nocov: end def apply(&block) load_feature(:base) instance_exec(&block) end def enable(*features) features.each do |feature| next if @auth.features.include?(feature) load_feature(feature) @auth.features << feature end end private def load_feature(feature_name) require "rodauth/features/#{feature_name}" unless FEATURES[feature_name] feature = FEATURES[feature_name] enable(*feature.dependencies) extend feature.configuration @auth.routes.concat(feature.routes) @auth.send(:include, feature) end end class Auth @features = [] @routes = [] @route_hash = {} @configuration = Configuration.new(self) class << self attr_accessor :roda_class attr_reader :features attr_reader :routes attr_accessor :route_hash attr_reader :configuration_name attr_reader :configuration end def self.inherited(subclass) super superclass = self subclass.instance_exec do @roda_class = superclass.roda_class @features = superclass.features.clone @routes = superclass.routes.clone @route_hash = superclass.route_hash.clone @configuration = superclass.configuration.clone @configuration.instance_variable_set(:@auth, self) end end def self.configure(&block) @configuration.apply(&block) end def self.freeze @features.freeze @routes.freeze @route_hash.freeze super end end module InstanceMethods def default_rodauth_name nil end def rodauth(name=default_rodauth_name) if name (@_rodauths ||= {})[name] ||= self.class.rodauth(name).new(self) else @_rodauth ||= self.class.rodauth.new(self) end end end module ClassMethods def rodauth(name=nil) opts[:rodauths][name] end def precompile_rodauth_templates instance = allocate rodauth = instance.rodauth view_opts = rodauth.send(:loaded_templates).map do |page| rodauth.send(:_view_opts, page) end view_opts << rodauth.send(:button_opts, '', {}) view_opts.each do |opts| instance.send(:retrieve_template, opts).send(:compiled_method, opts[:locals].keys.sort_by(&:to_s)) end nil end def freeze opts[:rodauths].each_value(&:freeze) opts[:rodauths].freeze super end end module RequestMethods def rodauth(name=scope.default_rodauth_name) scope.rodauth(name).route! end end end jeremyevans-rodauth-b53f402/lib/rodauth/000077500000000000000000000000001515725514200202605ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/lib/rodauth/features/000077500000000000000000000000001515725514200220765ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/lib/rodauth/features/account_expiration.rb000066400000000000000000000074601515725514200263300ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:account_expiration, :AccountExpiration) do error_flash "You cannot log into this account as it has expired" redirect after auth_value_method :account_activity_expired_column, :expired_at auth_value_method :account_activity_id_column, :id auth_value_method :account_activity_last_activity_column, :last_activity_at auth_value_method :account_activity_last_login_column, :last_login_at auth_value_method :account_activity_table, :account_activity_times auth_value_method :expire_account_after, 180*86400 auth_value_method :expire_account_on_last_activity?, false auth_methods( :account_expired?, :account_expired_at, :last_account_activity_at, :last_account_login_at, :set_expired, :update_last_activity, :update_last_login ) def last_account_activity_at get_activity_timestamp(session_value, account_activity_last_activity_column) end def last_account_login_at get_activity_timestamp(session_value, account_activity_last_login_column) end def account_expired_at get_activity_timestamp(account_id, account_activity_expired_column) end def update_last_login update_activity(account_id, account_activity_last_login_column, account_activity_last_activity_column) end def update_last_activity if session_value update_activity(session_value, account_activity_last_activity_column) end end def set_expired update_activity(account_id, account_activity_expired_column) after_account_expiration end def account_expired? columns = [account_activity_last_activity_column, account_activity_last_login_column, account_activity_expired_column] last_activity, last_login, expired = account_activity_ds(account_id).get(columns) return true if expired timestamp = convert_timestamp(expire_account_on_last_activity? ? last_activity : last_login) return false unless timestamp timestamp < Time.now - expire_account_after end def check_account_expiration if account_expired? set_expired unless account_expired_at set_redirect_error_flash account_expiration_error_flash redirect account_expiration_redirect end update_last_login end def update_session check_account_expiration super end private def before_reset_password check_account_expiration super if defined?(super) end def before_reset_password_request check_account_expiration super if defined?(super) end def before_unlock_account check_account_expiration super if defined?(super) end def before_unlock_account_request check_account_expiration super if defined?(super) end def after_close_account super if defined?(super) account_activity_ds(account_id).delete end def account_activity_ds(account_id) db[account_activity_table]. where(account_activity_id_column=>account_id) end def get_activity_timestamp(account_id, column) convert_timestamp(account_activity_ds(account_id).get(column)) end def update_activity(account_id, *columns) ds = account_activity_ds(account_id) hash = {} columns.each do |c| hash[c] = Sequel::CURRENT_TIMESTAMP end if ds.update(hash) == 0 hash[account_activity_id_column] = account_id hash[account_activity_last_activity_column] ||= Sequel::CURRENT_TIMESTAMP hash[account_activity_last_login_column] ||= Sequel::CURRENT_TIMESTAMP # It is safe to ignore uniqueness violations here, as a concurrent insert would also use current timestamps. ignore_uniqueness_violation{ds.insert(hash)} end end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/active_sessions.rb000066400000000000000000000145061515725514200256320ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:active_sessions, :ActiveSessions) do depends :logout error_flash 'This session has been logged out' redirect session_key :session_id_session_key, :active_session_id auth_value_method :active_sessions_account_id_column, :account_id auth_value_method :active_sessions_created_at_column, :created_at auth_value_method :active_sessions_last_use_column, :last_use auth_value_method :active_sessions_session_id_column, :session_id auth_value_method :active_sessions_table, :account_active_session_keys translatable_method :global_logout_label, 'Logout all Logged In Sessions?' auth_value_method :global_logout_param, 'global_logout' auth_value_method :inactive_session_error_status, 401 auth_value_method :session_inactivity_deadline, 86400 auth_value_method(:session_lifetime_deadline, 86400*30) auth_value_methods :update_current_session? auth_methods( :active_sessions_insert_hash, :active_sessions_key, :active_sessions_update_hash, :add_active_session, :currently_active_session?, :handle_duplicate_active_session_id, :no_longer_active_session, :remove_active_session, :remove_all_active_sessions, :remove_all_active_sessions_except_for, :remove_all_active_sessions_except_current, :remove_current_session, :remove_inactive_sessions, ) def currently_active_session? return false unless session_id = session[session_id_session_key] remove_inactive_sessions ds = active_sessions_ds. where(active_sessions_session_id_column => compute_hmacs(session_id)) if update_current_session? ds.update(active_sessions_update_hash) == 1 else ds.count == 1 end end def check_active_session if logged_in? && !currently_active_session? no_longer_active_session end end def no_longer_active_session clear_session set_redirect_error_status inactive_session_error_status set_error_reason :inactive_session set_redirect_error_flash active_sessions_error_flash redirect active_sessions_redirect end def add_active_session key = generate_active_sessions_key set_session_value(session_id_session_key, key) if e = raises_uniqueness_violation?{active_sessions_ds.insert(active_sessions_insert_hash)} handle_duplicate_active_session_id(e) end nil end def handle_duplicate_active_session_id(_e) # Do nothing by default as session is already tracked. This will result in # the current session and the existing session with the same id # being tracked together, so that a logout of one will logout # the other, and updating the last use on one will update the other, # but this should be acceptable. However, this can be overridden if different # behavior is desired. end def remove_current_session if session_id = session[session_id_session_key] remove_active_session(compute_hmacs(session_id)) end end def remove_active_session(session_id) active_sessions_ds.where(active_sessions_session_id_column=>session_id).delete end def remove_all_active_sessions active_sessions_ds.delete end def remove_all_active_sessions_except_for(session_id) active_sessions_ds.exclude(active_sessions_session_id_column=>compute_hmacs(session_id)).delete end def remove_all_active_sessions_except_current if session_id = session[session_id_session_key] remove_all_active_sessions_except_for(session_id) else remove_all_active_sessions end end def remove_inactive_sessions if cond = inactive_session_cond active_sessions_ds.where(cond).delete end end def logout_additional_form_tags super.to_s + render('global-logout-field') end def update_session remove_current_session super add_active_session end def clear_tokens(reason) super remove_all_active_sessions_except_current end private def after_refresh_token super if defined?(super) if prev_key = session[session_id_session_key] key = generate_active_sessions_key set_session_value(session_id_session_key, key) active_sessions_ds. where(active_sessions_session_id_column => compute_hmacs(prev_key)). update(active_sessions_session_id_column => compute_hmac(key)) end end def after_close_account super if defined?(super) remove_all_active_sessions end def before_logout if param_or_nil(global_logout_param) remove_remember_key(session_value) if respond_to?(:remove_remember_key) remove_all_active_sessions else remove_current_session end super end attr_reader :active_sessions_key def generate_active_sessions_key @active_sessions_key = random_key end def active_sessions_insert_hash {active_sessions_account_id_column => session_value, active_sessions_session_id_column => compute_hmac(active_sessions_key)} end def active_sessions_update_hash h = {active_sessions_last_use_column => Sequel::CURRENT_TIMESTAMP} if hmac_secret_rotation? h[active_sessions_session_id_column] = compute_hmac(session[session_id_session_key]) end h end def session_inactivity_deadline_condition if deadline = session_inactivity_deadline Sequel[active_sessions_last_use_column] < Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, seconds: deadline) end end def session_lifetime_deadline_condition if deadline = session_lifetime_deadline Sequel[active_sessions_created_at_column] < Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, seconds: deadline) end end def inactive_session_cond cond = session_inactivity_deadline_condition cond2 = session_lifetime_deadline_condition return false unless cond || cond2 Sequel.|(*[cond, cond2].compact) end def update_current_session? !!session_inactivity_deadline end def active_sessions_ds db[active_sessions_table]. where(active_sessions_account_id_column=>session_value || account_id) end def use_date_arithmetic? true end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/argon2.rb000066400000000000000000000070521515725514200236170ustar00rootroot00000000000000# frozen-string-literal: true require 'argon2' # :nocov: if !defined?(Argon2::VERSION) || Argon2::VERSION < '2' raise LoadError, "argon2 version 1.x not supported as it does not support argon2id hashes" end # :nocov: module Rodauth Feature.define(:argon2, :Argon2) do depends :login_password_requirements_base auth_value_method :argon2_old_secret, nil auth_value_method :argon2_secret, nil auth_value_method :use_argon2?, true def password_hash(password) return super unless use_argon2? if secret = argon2_secret argon2_params = Hash[password_hash_cost] argon2_params[:secret] = secret else argon2_params = password_hash_cost end ::Argon2::Password.new(argon2_params).create(password) end private if Argon2::VERSION != '2.1.0' def argon2_salt_option :salt_do_not_supply end # :nocov: else def argon2_salt_option :salt_for_testing_purposes_only end # :nocov: end def password_hash_cost return super unless use_argon2? argon2_hash_cost end def password_hash_match?(hash, password) return super unless argon2_hash_algorithm?(hash) argon2_password_hash_match?(hash, password) end def password_hash_using_salt(password, salt) return super unless argon2_hash_algorithm?(salt) argon2_password_hash_using_salt_and_secret(password, salt, argon2_secret) end def argon2_password_hash_using_salt_and_secret(password, salt, secret) argon2_params = Hash[extract_password_hash_cost(salt)] argon2_params[argon2_salt_option] = salt.split('$').last.unpack("m")[0] argon2_params[:secret] = secret ::Argon2::Password.new(argon2_params).create(password) end if Argon2::VERSION >= '2.1' def extract_password_hash_cost(hash) return super unless argon2_hash_algorithm?(hash) /\A\$argon2id\$v=\d+\$m=(\d+),t=(\d+),p=(\d+)/ =~ hash { t_cost: $2.to_i, m_cost: Math.log2($1.to_i).to_i, p_cost: $3.to_i } end if ENV['RACK_ENV'] == 'test' def argon2_hash_cost { t_cost: 1, m_cost: 5, p_cost: 1 } end # :nocov: else def argon2_hash_cost { t_cost: 2, m_cost: 16, p_cost: 1 } end end else def extract_password_hash_cost(hash) return super unless argon2_hash_algorithm?(hash ) /\A\$argon2id\$v=\d+\$m=(\d+),t=(\d+)/ =~ hash { t_cost: $2.to_i, m_cost: Math.log2($1.to_i).to_i } end if ENV['RACK_ENV'] == 'test' def argon2_hash_cost { t_cost: 1, m_cost: 5 } end else def argon2_hash_cost { t_cost: 2, m_cost: 16 } end end end # :nocov: def argon2_hash_algorithm?(hash) hash.start_with?('$argon2id$') end def argon2_password_hash_match?(hash, password) ret = ::Argon2::Password.verify_password(password, hash, argon2_secret) if ret == false && argon2_old_secret != argon2_secret && (ret = ::Argon2::Password.verify_password(password, hash, argon2_old_secret)) @update_password_hash = true end ret end def database_function_password_match?(name, hash_id, password, salt) return true if super if use_argon2? && argon2_hash_algorithm?(salt) && argon2_old_secret != argon2_secret && (ret = db.get(Sequel.function(function_name(name), hash_id, argon2_password_hash_using_salt_and_secret(password, salt, argon2_old_secret)))) @update_password_hash = true end !!ret end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/audit_logging.rb000066400000000000000000000054311515725514200252420ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:audit_logging, :AuditLogging) do auth_value_method :audit_logging_account_id_column, :account_id auth_value_method :audit_logging_message_column, :message auth_value_method :audit_logging_metadata_column, :metadata auth_value_method :audit_logging_table, :account_authentication_audit_logs auth_value_method :audit_log_metadata_default, nil auth_methods( :add_audit_log, :audit_log_insert_hash, :audit_log_message, :audit_log_message_default, :audit_log_metadata, :serialize_audit_log_metadata, ) configuration_module_eval do [:audit_log_message_for, :audit_log_metadata_for].each do |method| define_method(method) do |action, value=nil, &block| block ||= proc{value} meth = :"#{method}_#{action}" @auth.send(:define_method, meth, &block) @auth.send(:private, meth) end end end def hook_action(hook_type, action) super # In after_logout, session is already cleared, so use before_logout in that case if (hook_type == :after || action == :logout) && (id = account ? account_id : session_value) add_audit_log(id, action) end end def add_audit_log(account_id, action) if hash = audit_log_insert_hash(account_id, action) audit_log_ds.insert(hash) end end def audit_log_insert_hash(account_id, action) if message = audit_log_message(action) { audit_logging_account_id_column => account_id, audit_logging_message_column => message, audit_logging_metadata_column => serialize_audit_log_metadata(audit_log_metadata(action)) } end end def serialize_audit_log_metadata(metadata) metadata.to_json unless metadata.nil? end def audit_log_message_default(action) action.to_s end def audit_log_message(action) meth = :"audit_log_message_for_#{action}" if respond_to?(meth, true) send(meth) else audit_log_message_default(action) end end def audit_log_metadata(action) meth = :"audit_log_metadata_for_#{action}" if respond_to?(meth, true) send(meth) else audit_log_metadata_default end end private def audit_log_ds ds = db[audit_logging_table] # :nocov: if db.database_type == :postgres # :nocov: # For PostgreSQL, use RETURNING NULL. This allows the feature # to be used with INSERT but not SELECT permissions on the # table, useful for audit logging where the database user # the application is running as should not need to read the # logs. ds = ds.returning(nil) end ds end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/base.rb000066400000000000000000000655441515725514200233530ustar00rootroot00000000000000# frozen-string-literal: true require 'rack/request' require 'rack/utils' module Rodauth Feature.define(:base, :Base) do after 'login' after 'login_failure' before 'login' before 'login_attempt' before 'rodauth' error_flash "Please login to continue", 'require_login' auth_value_method :account_id_column, :id auth_value_method :account_open_status_value, 2 auth_value_method :account_password_hash_column, nil auth_value_method :account_select, nil auth_value_method :account_status_column, :status_id auth_value_method :account_unverified_status_value, 1 auth_value_method :accounts_table, :accounts auth_value_method :cache_templates, true auth_value_method :check_csrf_block, nil auth_value_method :check_csrf_opts, {}.freeze auth_value_method :default_redirect, '/' auth_value_method :convert_token_id_to_integer?, nil flash_key :flash_error_key, :error flash_key :flash_notice_key, :notice auth_value_method :hmac_old_secret, nil auth_value_method :hmac_secret, nil translatable_method :input_field_label_suffix, '' auth_value_method :input_field_error_class, 'error is-invalid' auth_value_method :input_field_error_message_class, 'error_message invalid-feedback' auth_value_method :invalid_field_error_status, 422 auth_value_method :invalid_key_error_status, 401 auth_value_method :invalid_password_error_status, 401 translatable_method :invalid_password_message, "invalid password" auth_value_method :login_column, :email auth_value_method :login_required_error_status, 401 auth_value_method :lockout_error_status, 403 auth_value_method :max_param_bytesize, 1024 auth_value_method :password_hash_id_column, :id auth_value_method :password_hash_column, :password_hash auth_value_method :password_hash_table, :account_password_hashes auth_value_method :no_matching_login_error_status, 401 translatable_method :no_matching_login_message, "no matching login" auth_value_method :login_param, 'login' translatable_method :login_label, 'Login' translatable_method :password_label, 'Password' auth_value_method :password_param, 'password' session_key :session_key, :account_id session_key :authenticated_by_session_key, :authenticated_by session_key :autologin_type_session_key, :autologin_type auth_value_method :prefix, '' auth_value_method :session_key_prefix, nil auth_value_method :require_bcrypt?, true auth_value_method :mark_input_fields_as_required?, true auth_value_method :mark_input_fields_with_autocomplete?, true auth_value_method :mark_input_fields_with_inputmode?, true auth_value_method :skip_status_checks?, true translatable_method :strftime_format, '%F %T' auth_value_method :template_opts, {}.freeze auth_value_method :title_instance_variable, nil auth_value_method :token_separator, "_" auth_value_method :unmatched_field_error_status, 422 auth_value_method :unopen_account_error_status, 403 translatable_method :unverified_account_message, "unverified account, please verify account before logging in" auth_value_method :default_field_attributes, '' auth_value_method :use_template_fixed_locals?, true redirect(:require_login){"#{prefix}/login"} auth_value_methods( :base_url, :check_csrf?, :db, :domain, :login_input_type, :login_uses_email?, :modifications_require_password?, :set_deadline_values?, :use_date_arithmetic?, :use_database_authentication_functions?, :use_request_specific_csrf_tokens? ) auth_methods( :account_id, :account_session_value, :already_logged_in, :authenticated?, :autocomplete_for_field?, :check_csrf, :clear_session, :clear_tokens, :csrf_tag, :function_name, :hook_action, :inputmode_for_field?, :logged_in?, :login_required, :normalize_login, :null_byte_parameter_value, :open_account?, :over_max_bytesize_param_value, :password_match?, :random_key, :redirect, :session_value, :set_error_flash, :set_notice_flash, :set_notice_now_flash, :set_redirect_error_flash, :set_error_reason, :set_title, :translate, :update_session ) auth_private_methods( :account_from_id, :account_from_login, :account_from_session, :convert_token_id, :field_attributes, :field_error_attributes, :formatted_field_error, :around_rodauth ) internal_request_method :account_exists? internal_request_method :account_id_for_login internal_request_method :internal_request_eval configuration_module_eval do def auth_class_eval(&block) auth.class_eval(&block) end end attr_reader :scope attr_reader :account attr_reader :current_route def initialize(scope) @scope = scope end def features self.class.features end def request scope.request end def response scope.response end def session scope.session end def flash scope.flash end def route! if meth = self.class.route_hash[request.remaining_path] send(meth) end nil end def set_field_error(field, error) (@field_errors ||= {})[field] = error end def field_error(field) return nil unless @field_errors @field_errors[field] end def add_field_error_class(field) if field_error(field) " #{input_field_error_class}" end end def input_field_string(param, id, opts={}) type = opts.fetch(:type, "text") unless type == "password" value = opts.fetch(:value){scope.h param(param)} end field_class = opts.fetch(:class, "form-control") if autocomplete_for_field?(param) && opts[:autocomplete] autocomplete = "autocomplete=\"#{opts[:autocomplete]}\"" end if inputmode_for_field?(param) && opts[:inputmode] inputmode = "inputmode=\"#{opts[:inputmode]}\"" end if mark_input_fields_as_required? && opts[:required] != false required = "required=\"required\"" end " #{formatted_field_error(param) unless opts[:skip_error_message]}" end def autocomplete_for_field?(_param) mark_input_fields_with_autocomplete? end def inputmode_for_field?(_param) mark_input_fields_with_inputmode? end def field_attributes(field) _field_attributes(field) || default_field_attributes end def field_error_attributes(field) if field_error(field) _field_error_attributes(field) end end def formatted_field_error(field) if error = field_error(field) _formatted_field_error(field, error) end end def hook_action(_hook_type, _action) # nothing by default end def translate(_key, default) # do not attempt to translate by default default end # Return urlsafe base64 HMAC for data, assumes hmac_secret is set. def compute_hmac(data) _process_raw_hmac(compute_raw_hmac(data)) end # Return urlsafe base64 HMAC for data using hmac_old_secret, assumes hmac_old_secret is set. def compute_old_hmac(data) _process_raw_hmac(compute_raw_hmac_with_secret(data, hmac_old_secret)) end # Return array of hmacs. Array has two strings if hmac_old_secret # is set, or one string otherwise. def compute_hmacs(data) hmacs = [compute_hmac(data)] if hmac_old_secret hmacs << compute_old_hmac(data) end hmacs end def account_id account[account_id_column] end alias account_session_value account_id def session_value session[session_key] end alias logged_in? session_value def account_from_login(login) @account = _account_from_login(login) end def open_account? skip_status_checks? || account[account_status_column] == account_open_status_value end def db Sequel::DATABASES.first or raise "Sequel database connection is missing" end def login_field_autocomplete_value login_uses_email? ? "email" : "on" end def password_field_autocomplete_value @password_field_autocomplete_value || 'current-password' end alias account_password_hash_column account_password_hash_column # If the account_password_hash_column is set, the password hash is verified in # ruby, it will not use a database function to do so, it will check the password # hash using bcrypt. def account_password_hash_column nil end def check_already_logged_in already_logged_in if logged_in? end def already_logged_in nil end def login_input_type login_uses_email? ? 'email' : 'text' end def login_uses_email? login_column == :email end def clear_session if use_scope_clear_session? scope.clear_session else session.clear end end def clear_tokens(reason) end def login_required set_redirect_error_status(login_required_error_status) set_error_reason :login_required set_redirect_error_flash require_login_error_flash redirect require_login_redirect end def set_title(title) if title_instance_variable scope.instance_variable_set(title_instance_variable, title) end end def set_error_flash(message) flash.now[flash_error_key] = message end def set_redirect_error_flash(message) flash[flash_error_key] = message end def set_notice_flash(message) flash[flash_notice_key] = message end def set_notice_now_flash(message) flash.now[flash_notice_key] = message end def require_login login_required unless logged_in? end def authenticated? logged_in? end def require_authentication require_login end def require_account require_authentication require_account_session end def account_initial_status_value account_open_status_value end def account! account || (session_value && account_from_session) end def account_from_session @account = _account_from_session end def account_from_id(id, status_id=nil) @account = _account_from_id(id, status_id) end def check_csrf scope.check_csrf!(check_csrf_opts, &check_csrf_block) end def csrf_tag(path=request.path) return unless scope.respond_to?(:csrf_tag) if use_request_specific_csrf_tokens? scope.csrf_tag(path) else # :nocov: scope.csrf_tag # :nocov: end end def button_opts(value, opts) opts = Hash[template_opts].merge!(opts) _merge_fixed_locals_opts(opts, button_fixed_locals) opts[:locals] = {:value=>value, :opts=>opts} opts[:cache] = cache_templates opts[:cache_key] = :rodauth_button _template_opts(opts, 'button') end def button(value, opts={}) scope.render(button_opts(value, opts)) end def view(page, title) set_title(title) _view(:view, page) end def render(page) _view(:render, page) end def only_json? scope.class.opts[:rodauth_json] == :only end def post_configure require 'bcrypt' if require_bcrypt? db.extension :date_arithmetic if use_date_arithmetic? if method(:convert_token_id_to_integer?).owner == Rodauth::Base && (db rescue false) && db.table_exists?(accounts_table) && db.schema(accounts_table).find{|col, v| break v[:type] == :integer if col == account_id_column} self.class.send(:define_method, :convert_token_id_to_integer?){true} end route_hash= {} self.class.routes.each do |meth| route_meth = "#{meth.to_s.sub(/\Ahandle_/, '')}_route" if route = send(route_meth) route_hash["/#{route}"] = meth end end self.class.route_hash = route_hash.freeze end def password_match?(password) if hash = get_password_hash if account_password_hash_column || !use_database_authentication_functions? password_hash_match?(hash, password) else database_function_password_match?(:rodauth_valid_password_hash, account_id, password, hash) end end end def update_session clear_session set_session_value(session_key, account_session_value) end def authenticated_by session[authenticated_by_session_key] end def login_session(auth_type) update_session set_session_value(authenticated_by_session_key, [auth_type]) end def autologin_type session[autologin_type_session_key] end def autologin_session(autologin_type) login_session('autologin') set_session_value(autologin_type_session_key, autologin_type) end # Return a string for the parameter name. This will be an empty # string if the parameter doesn't exist. def param(key) param_or_nil(key).to_s end # Return a string for the parameter name, or nil if there is no # parameter with that name. def param_or_nil(key) value = raw_param(key) unless value.nil? value = value.to_s value = over_max_bytesize_param_value(key, value) if max_param_bytesize && value.bytesize > max_param_bytesize value = null_byte_parameter_value(key, value) if value && value.include?("\0") end value end # Return nil by default for values over maximum bytesize. def over_max_bytesize_param_value(key, value) nil end # The normalized value of the login parameter def login_param_value normalize_login(param(login_param)) end def normalize_login(login) login end # Return nil by default for values with null bytes def null_byte_parameter_value(key, value) nil end def raw_param(key) request.params[key] end def base_url url = String.new("#{request.scheme}://#{domain}") url << ":#{request.port}" if request.port != Rack::Request::DEFAULT_PORTS[request.scheme] url end def domain request.host end def modifications_require_password? has_password? end def possible_authentication_methods has_password? ? ['password'] : [] end def has_password? return @has_password if defined?(@has_password) return false unless account || session_value @has_password = !!get_password_hash end private def _around_rodauth yield end def _process_raw_hmac(hmac) s = [hmac].pack('m') s.chomp!("=\n") s.tr!('+/', '-_') s end if Rack.release >= '3' def set_response_header(key, value) response.headers[key] = value end def convert_response_header_key(key) key end # :nocov: else def set_response_header(key, value) response.headers[convert_response_header_key(key)] = value end # Attempt backwards compatibility on Rack < 3 by changing # known cases from lower case to mixed case. mixed_case_headers = {} (<<-END).split.each { |k| mixed_case_headers[k.downcase.freeze] = k.freeze } Access-Control-Allow-Headers Access-Control-Allow-Methods Access-Control-Allow-Origin Access-Control-Expose-Headers Access-Control-Max-Age Allow Authorization Content-Type Content-Length WWW-Authenticate END mixed_case_headers.freeze define_method(:convert_response_header_key) do |key| mixed_case_headers.fetch(key, key) end end # :nocov: if RUBY_VERSION >= '2.1' def button_fixed_locals '(value:, opts:)' end # :nocov: else # Work on Ruby 2.0 when using Tilt 2.6+, as Ruby 2.0 does # not support required keyword arguments. def button_fixed_locals '(value: nil, opts: nil)' end end # :nocov: def database_function_password_match?(name, hash_id, password, salt) db.get(Sequel.function(function_name(name), hash_id, password_hash_using_salt(password, salt))) end def password_hash_match?(hash, password) BCrypt::Password.new(hash) == password end def convert_token_key(key) if key && hmac_secret compute_hmac(key) else key end end def split_token(token) token.split(token_separator, 2) end def convert_token_id(id) if convert_token_id_to_integer? convert_token_id_to_integer(id) else id end end def convert_token_id_to_integer(id) if id = (Integer(id, 10) rescue nil) if id > 9223372036854775807 || id < -9223372036854775808 # Only allow 64-bit signed integer range to avoid problems on PostgreSQL id = nil end end id end def redirect(path) request.redirect(path) end def return_response(body=nil) response.write(body) if body request.halt end def route_path(route, opts={}) path = "#{prefix}/#{route}" path += "?#{Rack::Utils.build_nested_query(opts)}" unless opts.empty? path end def route_url(route, opts={}) "#{base_url}#{route_path(route, opts)}" end def transaction(opts={}, &block) db.transaction(opts, &block) end def random_key SecureRandom.urlsafe_base64(32) end def convert_session_key(key) key = :"#{session_key_prefix}#{key}" if session_key_prefix normalize_session_or_flash_key(key) end def normalize_session_or_flash_key(key) scope.opts[:sessions_convert_symbols] ? key.to_s : key end def timing_safe_eql?(provided, actual) provided = provided.to_s Rack::Utils.secure_compare(provided.ljust(actual.length), actual) && provided.length == actual.length end def require_account_session unless account_from_session clear_session login_required end end def catch_error(&block) catch(:rodauth_error, &block) end # Don't set an error status when redirecting in an error case, as a redirect status is needed. def set_redirect_error_status(status) end def set_response_error_status(status) response.status = status end def set_response_error_reason_status(reason, status) set_error_reason(reason) set_response_error_status(status) end def throw_rodauth_error throw :rodauth_error end def throw_error(field, error) set_field_error(field, error) throw_rodauth_error end def throw_error_status(status, field, error) set_response_error_status(status) throw_error(field, error) end def set_error_reason(reason) end def throw_error_reason(reason, status, field, message) set_error_reason(reason) throw_error_status(status, field, message) end def use_date_arithmetic? set_deadline_values? end def set_deadline_values? db.database_type == :mysql end def use_database_authentication_functions? case db.database_type when :postgres, :mysql, :mssql true else # :nocov: false # :nocov: end end def use_request_specific_csrf_tokens? scope.opts[:rodauth_route_csrf] && scope.use_request_specific_csrf_tokens? end def check_csrf? scope.opts[:rodauth_route_csrf] end def function_name(name) if db.database_type == :mssql # :nocov: "dbo.#{name}" # :nocov: else name end end def password_hash_using_salt(password, salt) BCrypt::Engine.hash_secret(password, salt) end # Get the password hash for the user. When using database authentication functions, # note that only the salt is returned. def get_password_hash if account_password_hash_column account[account_password_hash_column] if account! elsif use_database_authentication_functions? db.get(Sequel.function(function_name(:rodauth_get_salt), account ? account_id : session_value)) else # :nocov: password_hash_ds.get(password_hash_column) # :nocov: end end def _account_from_login(login) ds = account_table_ds.where(login_column=>login) ds = ds.select(*account_select) if account_select ds = ds.where(account_status_column=>[account_unverified_status_value, account_open_status_value]) unless skip_status_checks? ds.first end def _account_from_session ds = account_ds(session_value) ds = ds.where(account_session_status_filter) unless skip_status_checks? ds.first end def _account_from_id(id, status_id=nil) ds = account_ds(id) ds = ds.where(account_status_column=>status_id) if status_id && !skip_status_checks? ds.first end def hmac_secret_rotation? hmac_secret && hmac_old_secret && hmac_secret != hmac_old_secret end def compute_raw_hmac(data) raise ConfigurationError, "hmac_secret not set" unless hmac_secret compute_raw_hmac_with_secret(data, hmac_secret) end def compute_raw_hmac_with_secret(data, secret) OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, secret, data) end def _field_attributes(field) nil end def _field_error_attributes(field) " aria-invalid=\"true\" aria-describedby=\"#{field}_error_message\" " end def _formatted_field_error(field, error) "#{error}" end def account_session_status_filter {account_status_column=>account_open_status_value} end def template_path(page) File.join(File.dirname(__FILE__), '../../../templates', "#{page}.str") end def account_ds(id=account_id) raise ArgumentError, "invalid account id passed to account_ds" unless id ds = account_table_ds.where(account_id_column=>id) ds = ds.select(*account_select) if account_select ds end def account_table_ds db[accounts_table] end def password_hash_ds db[password_hash_table].where(password_hash_id_column=>account ? account_id : session_value) end # This is needed for jdbc/sqlite, which returns timestamp columns as strings def convert_timestamp(timestamp) timestamp = db.to_application_timestamp(timestamp) if timestamp.is_a?(String) timestamp end def loaded_templates [] end # This is used to avoid race conditions when using the pattern of inserting when # an update affects no rows. In such cases, if a row is inserted between the # update and the insert, the insert will fail with a uniqueness error, but # retrying will work. It is possible for it to fail again, but only if the row # is deleted before the update and readded before the insert, which is very # unlikely to happen. In such cases, raising an exception is acceptable. def retry_on_uniqueness_violation(&block) if raises_uniqueness_violation?(&block) yield end end # In cases where retrying on uniqueness violations cannot work, this will detect # whether a uniqueness violation is raised by the block and return the exception if so. # This method should be used if you don't care about the exception itself. def raises_uniqueness_violation?(&block) transaction(:savepoint=>:only, &block) false rescue unique_constraint_violation_class => e e end # Work around jdbc/sqlite issue where it only raises ConstraintViolation and not # UniqueConstraintViolation. def unique_constraint_violation_class if db.adapter_scheme == :jdbc && db.database_type == :sqlite # :nocov: Sequel::ConstraintViolation # :nocov: else Sequel::UniqueConstraintViolation end end # If you would like to operate/reraise the exception, this alias makes more sense. alias raised_uniqueness_violation raises_uniqueness_violation? # If you just want to ignore uniqueness violations, this alias makes more sense. alias ignore_uniqueness_violation raises_uniqueness_violation? # This is needed on MySQL, which doesn't support non constant defaults other than # CURRENT_TIMESTAMP. def set_deadline_value(hash, column, interval) if set_deadline_values? # :nocov: hash[column] = Sequel.date_add(Sequel::CURRENT_TIMESTAMP, interval) # :nocov: end end def _filter_links(links) links.select!{|_, link| link} links.sort! links end def internal_request? false end def use_scope_clear_session? scope.respond_to?(:clear_session) end def require_response(meth) send(meth) raise ConfigurationError, "#{meth.to_s.sub(/\A_/, '')} overridden without returning a response (should use redirect or request.halt)." end def set_session_value(key, value) session[key] = value end def remove_session_value(key) session.delete(key) end def update_hash_ds(hash, ds, values) num = ds.update(values) if num == 1 values.each do |k, v| hash[k] = Sequel::CURRENT_TIMESTAMP == v ? Time.now : v end end num end def update_account(values, ds=account_ds) update_hash_ds(account, ds, values) end def _view_opts(page) opts = template_opts.dup _merge_fixed_locals_opts(opts, '(rodauth: self.rodauth)') opts[:locals] = opts[:locals] ? opts[:locals].dup : {} opts[:locals][:rodauth] = self opts[:cache] = cache_templates opts[:cache_key] = :"rodauth_#{page}" _template_opts(opts, page) end def _merge_fixed_locals_opts(opts, fixed_locals) if use_template_fixed_locals? && !opts[:locals] fixed_locals_opts = {default_fixed_locals: fixed_locals} fixed_locals_opts.merge!(opts[:template_opts]) if opts[:template_opts] opts[:template_opts] = fixed_locals_opts end end # Set the template path only if there isn't an overridden template in the application. # Result should replace existing template opts. def _template_opts(opts, page) opts = scope.send(:find_template, scope.send(:parse_template_opts, page, opts)) unless File.file?(scope.send(:template_path, opts)) opts[:path] = template_path(page) end opts end def _view(meth, page) unless scope.respond_to?(meth) raise ConfigurationError, "attempted to render a built-in view/email template (#{page.inspect}), but rendering is disabled" end scope.send(meth, _view_opts(page)) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/change_login.rb000066400000000000000000000054031515725514200250420ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:change_login, :ChangeLogin) do depends :login_password_requirements_base notice_flash 'Your login has been changed' error_flash 'There was an error changing your login' translatable_method :same_as_current_login_message, 'same as current login' loaded_templates %w'change-login login-field login-confirm-field password-field' view 'change-login', 'Change Login' after before additional_form_tags button 'Change Login' redirect response auth_value_methods :change_login_requires_password? auth_methods :change_login internal_request_method route do |r| require_account before_change_login_route r.get do change_login_view end r.post do catch_error do if change_login_requires_password? && !password_match?(param(password_param)) throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message) end login = login_param_value unless login_meets_requirements?(login) throw_error_status(invalid_field_error_status, login_param, login_does_not_meet_requirements_message) end if require_login_confirmation? && !login_confirmation_matches?(login, param(login_confirm_param)) throw_error_reason(:logins_do_not_match, unmatched_field_error_status, login_param, logins_do_not_match_message) end transaction do before_change_login unless change_login(login) throw_error_status(invalid_field_error_status, login_param, login_does_not_meet_requirements_message) end after_change_login end change_login_response end set_error_flash change_login_error_flash change_login_view end end def change_login_requires_password? modifications_require_password? end def change_login(login) if account_ds.get(login_column).downcase == login.downcase set_login_requirement_error_message(:same_as_current_login, same_as_current_login_message) return false end update_login(login) end private def update_login(login) _update_login(login) end def _update_login(login) updated = nil raised = raises_uniqueness_violation?{updated = update_account({login_column=>login}, account_ds.exclude(login_column=>login)) == 1} if raised set_login_requirement_error_message(:already_an_account_with_this_login, already_an_account_with_this_login_message) end change_made = updated && !raised clear_tokens(:change_login) if change_made change_made end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/change_password.rb000066400000000000000000000044151515725514200255760ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:change_password, :ChangePassword) do depends :login_password_requirements_base notice_flash 'Your password has been changed' error_flash 'There was an error changing your password' loaded_templates %w'change-password password-field password-confirm-field' view 'change-password', 'Change Password' after before additional_form_tags button 'Change Password' redirect response translatable_method :new_password_label, 'New Password' auth_value_method :new_password_param, 'new-password' auth_value_methods( :change_password_requires_password?, :invalid_previous_password_message ) internal_request_method route do |r| require_account before_change_password_route r.get do change_password_view end r.post do catch_error do if change_password_requires_password? && !password_match?(param(password_param)) throw_error_reason(:invalid_previous_password, invalid_password_error_status, password_param, invalid_previous_password_message) end password = param(new_password_param) if require_password_confirmation? && password != param(password_confirm_param) throw_error_reason(:passwords_do_not_match, unmatched_field_error_status, new_password_param, passwords_do_not_match_message) end if password_match?(password) throw_error_reason(:same_as_existing_password, invalid_field_error_status, new_password_param, same_as_existing_password_message) end unless password_meets_requirements?(password) throw_error_status(invalid_field_error_status, new_password_param, password_does_not_meet_requirements_message) end transaction do before_change_password set_password(password) after_change_password end change_password_response end set_error_flash change_password_error_flash change_password_view end end def change_password_requires_password? modifications_require_password? end def invalid_previous_password_message invalid_password_message end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/change_password_notify.rb000066400000000000000000000005761515725514200271720ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:change_password_notify, :ChangePasswordNotify) do depends :change_password, :email_base loaded_templates %w'password-changed-email' email :password_changed, 'Password Changed', :translatable=>true private def after_change_password super send_password_changed_email end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/close_account.rb000066400000000000000000000036711515725514200252530ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:close_account, :CloseAccount) do notice_flash 'Your account has been closed' error_flash 'There was an error closing your account' loaded_templates %w'close-account password-field' view 'close-account', 'Close Account' additional_form_tags button 'Close Account' after before redirect response auth_value_method :account_closed_status_value, 3 auth_value_methods( :close_account_requires_password?, :delete_account_on_close? ) auth_methods( :close_account, :delete_account ) internal_request_method route do |r| require_account before_close_account_route r.get do close_account_view end r.post do catch_error do if close_account_requires_password? && !password_match?(param(password_param)) throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message) end transaction do before_close_account close_account after_close_account clear_session clear_tokens(:close_account) if delete_account_on_close? delete_account end end close_account_response end set_error_flash close_account_error_flash close_account_view end end def close_account_requires_password? modifications_require_password? end def close_account unless skip_status_checks? update_account(account_status_column=>account_closed_status_value) end unless account_password_hash_column password_hash_ds.delete end end def delete_account account_ds.delete end def delete_account_on_close? skip_status_checks? end def skip_status_checks? false end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/confirm_password.rb000066400000000000000000000056431515725514200260120ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:confirm_password, :ConfirmPassword) do notice_flash "Your password has been confirmed" error_flash "There was an error confirming your password" error_flash "You need to confirm your password before continuing", 'password_authentication_required' loaded_templates %w'confirm-password password-field' view 'confirm-password', 'Confirm Password' additional_form_tags button 'Confirm Password' before after response redirect(:password_authentication_required){confirm_password_path} session_key :confirm_password_redirect_session_key, :confirm_password_redirect translatable_method :confirm_password_link_text, "Enter Password" auth_value_method :password_authentication_required_error_status, 401 auth_value_methods :confirm_password_redirect auth_methods :confirm_password route do |r| require_login require_account_session before_confirm_password_route r.get do confirm_password_view end r.post do if password_match?(param(password_param)) transaction do before_confirm_password confirm_password after_confirm_password end confirm_password_response else set_response_error_reason_status(:invalid_password, invalid_password_error_status) set_field_error(password_param, invalid_password_message) set_error_flash confirm_password_error_flash confirm_password_view end end end def require_password_authentication require_login if require_password_authentication? && has_password? set_redirect_error_status(password_authentication_required_error_status) set_error_reason :password_authentication_required set_redirect_error_flash password_authentication_required_error_flash set_session_value(confirm_password_redirect_session_key, request.fullpath) redirect password_authentication_required_redirect end end def confirm_password authenticated_by.delete('autologin') authenticated_by.delete('remember') authenticated_by.delete('email_auth') authenticated_by.delete('password') authenticated_by.unshift("password") remove_session_value(autologin_type_session_key) nil end def confirm_password_redirect remove_session_value(confirm_password_redirect_session_key) || default_redirect end private def _two_factor_auth_links links = (super if defined?(super)) || [] if authenticated_by.length == 1 && !authenticated_by.include?('password') && has_password? links << [5, confirm_password_path, confirm_password_link_text] end links end def require_password_authentication? return true if defined?(super) && super !authenticated_by.include?('password') end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/create_account.rb000066400000000000000000000073431515725514200254110ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:create_account, :CreateAccount) do depends :login, :login_password_requirements_base notice_flash 'Your account has been created' error_flash "There was an error creating your account" loaded_templates %w'create-account login-field login-confirm-field password-field password-confirm-field' view 'create-account', 'Create Account' after before button 'Create Account' additional_form_tags redirect response auth_value_method :create_account_autologin?, true translatable_method :create_account_link_text, "Create a New Account" auth_value_method :create_account_set_password?, true auth_methods( :save_account, :set_new_account_password ) auth_private_methods( :new_account ) internal_request_method route do |r| check_already_logged_in before_create_account_route @password_field_autocomplete_value = 'new-password' r.get do create_account_view end r.post do login = login_param_value password = param(password_param) new_account(login) catch_error do if require_login_confirmation? && !login_confirmation_matches?(login, param(login_confirm_param)) throw_error_reason(:logins_do_not_match, unmatched_field_error_status, login_param, logins_do_not_match_message) end unless login_meets_requirements?(login) throw_error_status(invalid_field_error_status, login_param, login_does_not_meet_requirements_message) end if create_account_set_password? if require_password_confirmation? && password != param(password_confirm_param) throw_error_reason(:passwords_do_not_match, unmatched_field_error_status, password_param, passwords_do_not_match_message) end unless password_meets_requirements?(password) throw_error_reason(:password_does_not_meet_requirements, invalid_field_error_status, password_param, password_does_not_meet_requirements_message) end if account_password_hash_column set_new_account_password(password) end end transaction do before_create_account unless save_account throw_error_status(invalid_field_error_status, login_param, login_does_not_meet_requirements_message) end if create_account_set_password? && !account_password_hash_column set_password(password) end after_create_account if create_account_autologin? autologin_session('create_account') end create_account_response end end set_error_flash create_account_error_flash create_account_view end end def set_new_account_password(password) account[account_password_hash_column] = password_hash(password) end def new_account(login) @account = _new_account(login) end def save_account id = nil raised = raises_uniqueness_violation?{id = db[accounts_table].insert(account)} if raised set_login_requirement_error_message(:already_an_account_with_this_login, already_an_account_with_this_login_message) end if id account[account_id_column] ||= id end id && !raised end private def _login_form_footer_links super << [10, create_account_path, create_account_link_text] end def _new_account(login) acc = {login_column=>login} unless skip_status_checks? acc[account_status_column] = account_initial_status_value end acc end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/disallow_common_passwords.rb000066400000000000000000000024441515725514200277220ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:disallow_common_passwords, :DisallowCommonPasswords) do depends :login_password_requirements_base auth_value_method :most_common_passwords_file, File.expand_path('../../../../dict/top-10_000-passwords.txt', __FILE__) translatable_method :password_is_one_of_the_most_common_message, "is one of the most common passwords" auth_value_method :most_common_passwords, nil auth_methods :password_one_of_most_common? def password_meets_requirements?(password) super && password_not_one_of_the_most_common?(password) end def post_configure super return if most_common_passwords || !most_common_passwords_file require 'set' most_common = Set.new(File.read(most_common_passwords_file).split("\n").each(&:freeze)).freeze self.class.send(:define_method, :most_common_passwords){most_common} end def password_one_of_most_common?(password) most_common_passwords.include?(password) end private def password_not_one_of_the_most_common?(password) return true unless password_one_of_most_common?(password) set_password_requirement_error_message(:password_is_one_of_the_most_common, password_is_one_of_the_most_common_message) false end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/disallow_password_reuse.rb000066400000000000000000000062521515725514200273730ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:disallow_password_reuse, :DisallowPasswordReuse) do depends :login_password_requirements_base translatable_method :password_same_as_previous_password_message, "same as previous password" auth_value_method :previous_password_account_id_column, :account_id auth_value_method :previous_password_hash_column, :password_hash auth_value_method :previous_password_hash_table, :account_previous_password_hashes auth_value_method :previous_password_id_column, :id auth_value_method :previous_passwords_to_check, 6 auth_methods( :add_previous_password_hash, :password_doesnt_match_previous_password? ) def set_password(password) hash = super add_previous_password_hash(hash) hash end def add_previous_password_hash(hash) ds = previous_password_ds unless @dont_check_previous_password keep_before = ds.reverse(previous_password_id_column). limit(nil, previous_passwords_to_check). get(previous_password_id_column) if keep_before ds.where(Sequel.expr(previous_password_id_column) <= keep_before). delete end end # This should never raise uniqueness violations, as it uses a serial primary key ds.insert(previous_password_account_id_column=>account_id, previous_password_hash_column=>hash) end def password_meets_requirements?(password) super && (@dont_check_previous_password || password_doesnt_match_previous_password?(password)) end private def password_doesnt_match_previous_password?(password) match = if use_database_authentication_functions? salts = previous_password_ds. select_map([previous_password_id_column, Sequel.function(function_name(:rodauth_get_previous_salt), previous_password_id_column).as(:salt)]) return true if salts.empty? salts.any? do |hash_id, salt| database_function_password_match?(:rodauth_previous_password_hash_match, hash_id, password, salt) end else # :nocov: previous_password_ds.select_map(previous_password_hash_column).any? do |hash| password_hash_match?(hash, password) end # :nocov: end return true unless match set_password_requirement_error_message(:password_same_as_previous_password, password_same_as_previous_password_message) false end def after_close_account super if defined?(super) previous_password_ds.delete end def before_create_account_route super if defined?(super) @dont_check_previous_password = true end def before_verify_account_route super if defined?(super) @dont_check_previous_password = true end def after_create_account if account_password_hash_column && !(respond_to?(:verify_account_set_password?) && verify_account_set_password?) add_previous_password_hash(password_hash(param(password_param))) end super if defined?(super) end def previous_password_ds db[previous_password_hash_table].where(previous_password_account_id_column=>account_id) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/email_auth.rb000066400000000000000000000170771515725514200245470ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:email_auth, :EmailAuth) do depends :login, :email_base notice_flash "An email has been sent to you with a link to login to your account", 'email_auth_email_sent' error_flash "There was an error logging you in" error_flash "There was an error requesting an email link to authenticate", 'email_auth_request' error_flash "An email has recently been sent to you with a link to login", 'email_auth_email_recently_sent' error_flash "There was an error logging you in: invalid email authentication key", 'no_matching_email_auth_key' loaded_templates %w'email-auth email-auth-request-form email-auth-email' view 'email-auth', 'Login' additional_form_tags additional_form_tags 'email_auth_request' before 'email_auth_request' after 'email_auth_request' button 'Send Login Link Via Email', 'email_auth_request' redirect(:email_auth_email_sent){default_post_email_redirect} redirect(:email_auth_email_recently_sent){default_post_email_redirect} response :email_auth_email_sent email :email_auth, 'Login Link' auth_value_method :email_auth_deadline_column, :deadline auth_value_method :email_auth_deadline_interval, {:days=>1}.freeze auth_value_method :email_auth_id_column, :id auth_value_method :email_auth_key_column, :key auth_value_method :email_auth_key_param, 'key' auth_value_method :email_auth_email_last_sent_column, :email_last_sent auth_value_method :email_auth_skip_resend_email_within, 300 auth_value_method :email_auth_table, :account_email_auth_keys auth_value_method :force_email_auth?, false session_key :email_auth_session_key, :email_auth_key auth_methods( :create_email_auth_key, :email_auth_email_link, :email_auth_key_insert_hash, :email_auth_key_value, :email_auth_request_form, :get_email_auth_key, :get_email_auth_email_last_sent, :remove_email_auth_key, :set_email_auth_email_last_sent ) auth_private_methods :account_from_email_auth_key internal_request_method internal_request_method :email_auth_request internal_request_method :valid_email_auth? route(:email_auth_request) do |r| check_already_logged_in before_email_auth_request_route r.post do if account_from_login(login_param_value) && open_account? _email_auth_request end set_redirect_error_status(no_matching_login_error_status) set_error_reason :no_matching_login set_redirect_error_flash email_auth_request_error_flash redirect email_auth_email_sent_redirect end end route do |r| check_already_logged_in before_email_auth_route r.get do if key = param_or_nil(email_auth_key_param) set_session_value(email_auth_session_key, key) redirect(r.path) end if (key = session[email_auth_session_key]) && account_from_email_auth_key(key) email_auth_view else remove_session_value(email_auth_session_key) set_redirect_error_flash no_matching_email_auth_key_error_flash redirect require_login_redirect end end r.post do key = session[email_auth_session_key] || param(email_auth_key_param) unless account_from_email_auth_key(key) set_redirect_error_status(invalid_key_error_status) set_error_reason :invalid_email_auth_key set_redirect_error_flash email_auth_error_flash redirect email_auth_email_sent_redirect end login('email_auth') end end def create_email_auth_key transaction do if email_auth_key_value = get_email_auth_key(account_id) set_email_auth_email_last_sent @email_auth_key_value = email_auth_key_value elsif e = raised_uniqueness_violation{email_auth_ds.insert(email_auth_key_insert_hash)} # If inserting into the email auth table causes a violation, we can pull the # existing email auth key from the table, or reraise. raise e unless @email_auth_key_value = get_email_auth_key(account_id) end end end def set_email_auth_email_last_sent email_auth_ds.update(email_auth_email_last_sent_column=>Sequel::CURRENT_TIMESTAMP) if email_auth_email_last_sent_column end def get_email_auth_email_last_sent if column = email_auth_email_last_sent_column if ts = email_auth_ds.get(column) convert_timestamp(ts) end end end def remove_email_auth_key email_auth_ds.delete end def account_from_email_auth_key(key) @account = _account_from_email_auth_key(key) end def email_auth_email_link token_link(email_auth_route, email_auth_key_param, email_auth_key_value) end def get_email_auth_key(id) ds = email_auth_ds(id) ds.where(Sequel::CURRENT_TIMESTAMP > email_auth_deadline_column).delete ds.get(email_auth_key_column) end def email_auth_request_form render('email-auth-request-form') end def after_login_entered_during_multi_phase_login # If forcing email auth, just send the email link. _email_auth_request if force_email_auth? super end def use_multi_phase_login? true end def possible_authentication_methods methods = super methods << 'email_auth' if !methods.include?('password') && allow_email_auth? methods end def email_auth_email_recently_sent? (email_last_sent = get_email_auth_email_last_sent) && (Time.now - email_last_sent < email_auth_skip_resend_email_within) end def clear_tokens(reason) super remove_email_auth_key end private def _multi_phase_login_forms forms = super forms << [30, email_auth_request_form, :_email_auth_request] if valid_login_entered? && allow_email_auth? forms end def _email_auth_request if email_auth_email_recently_sent? set_redirect_error_flash email_auth_email_recently_sent_error_flash redirect email_auth_email_recently_sent_redirect end generate_email_auth_key_value transaction do before_email_auth_request create_email_auth_key send_email_auth_email after_email_auth_request end email_auth_email_sent_response end attr_reader :email_auth_key_value def allow_email_auth? defined?(super) ? super : true end def after_login # Remove the email auth key after any login, even if # it is a password login. This is done to invalidate # the email login when a user has a password and requests # email authentication, but then remembers their password # and doesn't need the link. At that point, the link # that allows login access to the account becomes a # security liability, and it is best to remove it. remove_email_auth_key super end def generate_email_auth_key_value @email_auth_key_value = random_key end def use_date_arithmetic? super || db.database_type == :mysql end def email_auth_key_insert_hash hash = {email_auth_id_column=>account_id, email_auth_key_column=>email_auth_key_value} set_deadline_value(hash, email_auth_deadline_column, email_auth_deadline_interval) hash end def email_auth_ds(id=account_id) db[email_auth_table].where(email_auth_id_column=>id) end def _account_from_email_auth_key(token) account_from_key(token, account_open_status_value){|id| get_email_auth_key(id)} end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/email_base.rb000066400000000000000000000033451515725514200245110ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:email_base, :EmailBase) do translatable_method :email_subject_prefix, '' auth_value_method :require_mail?, true auth_value_method :allow_raw_email_token?, false redirect :default_post_email auth_value_methods( :email_from ) auth_methods( :create_email, :email_to, :send_email ) def post_configure super require 'mail' if require_mail? end def email_from "webmaster@#{domain}" end def email_to account[login_column] end private def send_email(email) email.deliver! end def create_email(subject, body) create_email_to(email_to, subject, body) end def create_email_to(to, subject, body) m = Mail.new m.from = email_from m.to = to m.subject = "#{email_subject_prefix}#{subject}" m.body = body m end def token_link(route, param, key) route_url(route, param => token_param_value(key)) end def token_param_value(key) "#{account_id}#{token_separator}#{convert_email_token_key(key)}" end def convert_email_token_key(key) convert_token_key(key) end def account_from_key(token, status_id=nil) id, key = split_token(token) id = convert_token_id(id) return unless id && key return unless actual = yield(id) unless (hmac_secret && timing_safe_eql?(key, convert_email_token_key(actual))) || (hmac_secret_rotation? && timing_safe_eql?(key, compute_old_hmac(actual))) || ((!hmac_secret || allow_raw_email_token?) && timing_safe_eql?(key, actual)) return end _account_from_id(id, status_id) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/http_basic_auth.rb000066400000000000000000000036471515725514200255760ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:http_basic_auth, :HttpBasicAuth) do auth_value_method :http_basic_auth_realm, "protected" auth_value_method :require_http_basic_auth?, false def logged_in? ret = super if !ret && !defined?(@checked_http_basic_auth) http_basic_auth ret = super end ret end def require_login if require_http_basic_auth? require_http_basic_auth end super end def require_http_basic_auth unless http_basic_auth set_http_basic_auth_error_response return_response end end def http_basic_auth return @checked_http_basic_auth if defined?(@checked_http_basic_auth) @checked_http_basic_auth = nil return unless token = ((v = request.env['HTTP_AUTHORIZATION']) && v[/\A *Basic (.*)\Z/, 1]) username, password = token.unpack("m*").first.split(/:/, 2) return unless username && password catch_error do unless account_from_login(username) throw_basic_auth_error(login_param, no_matching_login_message) end before_login_attempt unless open_account? throw_basic_auth_error(login_param, no_matching_login_message) end unless password_match?(password) after_login_failure throw_basic_auth_error(password_param, invalid_password_message) end transaction do before_login login_session('password') after_login end @checked_http_basic_auth = true return true end nil end private def set_http_basic_auth_error_response response.status = 401 set_response_header("www-authenticate", "Basic realm=\"#{http_basic_auth_realm}\"") end def throw_basic_auth_error(*args) set_http_basic_auth_error_response throw_error(*args) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/internal_request.rb000066400000000000000000000271011515725514200260100ustar00rootroot00000000000000# frozen-string-literal: true require 'stringio' module Rodauth INVALID_DOMAIN = "invalidurl @@.com" class InternalRequestError < StandardError attr_accessor :flash attr_accessor :reason attr_accessor :field_errors def initialize(attrs) return super if attrs.is_a?(String) @flash = attrs[:flash] @reason = attrs[:reason] @field_errors = attrs[:field_errors] || {} super(build_message) end private def build_message extras = [] extras << reason if reason extras << field_errors unless field_errors.empty? extras = (" (#{extras.join(", ")})" unless extras.empty?) "#{flash}#{extras}" end end module InternalRequestMethods attr_accessor :session attr_accessor :params attr_reader :flash attr_accessor :internal_request_block def domain d = super if d.nil? || d == INVALID_DOMAIN raise InternalRequestError, "must set domain in configuration, as it cannot be determined from internal request" end d end def raw_param(k) @params[k] end def clear_session @session.clear end def set_error_flash(message) @flash = message _handle_internal_request_error end alias set_redirect_error_flash set_error_flash def set_notice_flash(message) @flash = message end alias set_notice_now_flash set_notice_flash def modifications_require_password? false end alias require_login_confirmation? modifications_require_password? alias require_password_confirmation? modifications_require_password? alias change_login_requires_password? modifications_require_password? alias change_password_requires_password? modifications_require_password? alias close_account_requires_password? modifications_require_password? alias two_factor_modifications_require_password? modifications_require_password? def otp_setup_view hash = {:otp_setup=>otp_user_key} hash[:otp_setup_raw] = otp_key if hmac_secret _return_from_internal_request(hash) end def add_recovery_codes_view _return_from_internal_request(recovery_codes) end def webauthn_setup_view cred = new_webauthn_credential _return_from_internal_request({ webauthn_setup: cred.as_json, webauthn_setup_challenge: cred.challenge, webauthn_setup_challenge_hmac: compute_hmac(cred.challenge) }) end def webauthn_auth_view cred = webauthn_credential_options_for_get _return_from_internal_request({ webauthn_auth: cred.as_json, webauthn_auth_challenge: cred.challenge, webauthn_auth_challenge_hmac: compute_hmac(cred.challenge) }) end def handle_internal_request(meth) catch(:halt) do _around_rodauth do before_rodauth send(meth, request) end end @internal_request_return_value end def only_json? false end private def internal_request? true end def set_error_reason(reason) @error_reason = reason end def after_login super _set_internal_request_return_value(account_id) unless @return_false_on_error end def after_remember super if params[remember_param] == remember_remember_param_value _set_internal_request_return_value("#{account_id}_#{convert_token_key(remember_key_value)}") end end def after_load_memory super _return_from_internal_request(session_value) end def before_change_password_route super params[new_password_param] ||= params[password_param] end def before_email_auth_request_route super _set_login_param_from_account end def before_login_route super _set_login_param_from_account end def before_unlock_account_request_route super _set_login_param_from_account end def before_reset_password_request_route super _set_login_param_from_account end def before_verify_account_resend_route super _set_login_param_from_account end def before_webauthn_login_route super _set_login_param_from_account end def account_from_key(token, status_id=nil) return super unless session_value return unless yield session_value _account_from_id(session_value, status_id) end def _set_internal_request_return_value(value) @internal_request_return_value = value end def _return_from_internal_request(value) _set_internal_request_return_value(value) throw(:halt) end def _handle_internal_request_error if @return_false_on_error _return_from_internal_request(false) else raise InternalRequestError.new(flash: @flash, reason: @error_reason, field_errors: @field_errors) end end def _return_false_on_error! @return_false_on_error = true end def _set_login_param_from_account if session_value && !params[login_param] && (account = _account_from_id(session_value)) params[login_param] = account[login_column] end end def _get_remember_cookie params[remember_param] end def _handle_internal_request_eval(_) v = instance_eval(&internal_request_block) _set_internal_request_return_value(v) unless defined?(@internal_request_return_value) end def _handle_account_id_for_login(_) raise InternalRequestError, "no login provided" unless param_or_nil(login_param) raise InternalRequestError, "no account for login" unless account = account_from_login(login_param_value) _return_from_internal_request(account[account_id_column]) end def _handle_account_exists?(_) raise InternalRequestError, "no login provided" unless param_or_nil(login_param) _return_from_internal_request(!!account_from_login(login_param_value)) end def _handle_lock_account(_) raised_uniqueness_violation{account_lockouts_ds(session_value).insert(_setup_account_lockouts_hash(session_value, generate_unlock_account_key))} end def _handle_remember_setup(request) params[remember_param] = remember_remember_param_value _handle_remember(request) end def _handle_remember_disable(request) params[remember_param] = remember_disable_param_value _handle_remember(request) end def _handle_account_id_for_remember_key(request) load_memory raise InternalRequestError, "invalid remember key" end def _handle_otp_setup_params(request) request.env['REQUEST_METHOD'] = 'GET' _handle_otp_setup(request) end def _handle_webauthn_setup_params(request) request.env['REQUEST_METHOD'] = 'GET' _handle_webauthn_setup(request) end def _handle_webauthn_auth_params(request) request.env['REQUEST_METHOD'] = 'GET' _handle_webauthn_auth(request) end def _handle_webauthn_login_params(request) _set_login_param_from_account unless webauthn_login_options? raise InternalRequestError, "no login provided" unless param_or_nil(login_param) raise InternalRequestError, "no account for login" end webauthn_auth_view end def _predicate_internal_request(meth, request) _return_false_on_error! _set_internal_request_return_value(true) send(meth, request) end def _handle_valid_login_and_password?(request) _predicate_internal_request(:_handle_login, request) end def _handle_valid_email_auth?(request) _predicate_internal_request(:_handle_email_auth, request) end def _handle_valid_otp_auth?(request) _predicate_internal_request(:_handle_otp_auth, request) end def _handle_valid_recovery_auth?(request) _predicate_internal_request(:_handle_recovery_auth, request) end def _handle_valid_sms_auth?(request) _predicate_internal_request(:_handle_sms_auth, request) end end module InternalRequestClassMethods def internal_request(route, opts={}, &block) opts = opts.dup env = { 'REQUEST_METHOD'=>'POST', 'PATH_INFO'=>'/'.dup, "SCRIPT_NAME" => "", "HTTP_HOST" => INVALID_DOMAIN, "SERVER_NAME" => INVALID_DOMAIN, "SERVER_PORT" => 443, "CONTENT_TYPE" => "application/x-www-form-urlencoded", "rack.input"=>StringIO.new(''), "rack.url_scheme"=>"https" } env.merge!(opts.delete(:env)) if opts[:env] session = {} session.merge!(opts.delete(:session)) if opts[:session] params = {} params.merge!(opts.delete(:params)) if opts[:params] scope = roda_class.new(env) rodauth = new(scope) rodauth.session = session rodauth.params = params rodauth.internal_request_block = block unless account_id = opts.delete(:account_id) if (account_login = opts.delete(:account_login)) if (account = rodauth.send(:_account_from_login, account_login)) account_id = account[rodauth.account_id_column] else raise InternalRequestError, "no account for login: #{account_login.inspect}" end end end if account_id session[rodauth.session_key] = account_id unless authenticated_by = opts.delete(:authenticated_by) authenticated_by = case route when :otp_auth, :sms_request, :sms_auth, :recovery_auth, :webauthn_auth, :webauthn_auth_params, :valid_otp_auth?, :valid_sms_auth?, :valid_recovery_auth? ['internal1'] else ['internal1', 'internal2'] end end session[rodauth.authenticated_by_session_key] = authenticated_by end opts.keys.each do |k| meth = :"#{k}_param" params[rodauth.public_send(meth).to_s] = opts.delete(k) if rodauth.respond_to?(meth) end unless opts.empty? warn "unhandled options passed to #{route}: #{opts.inspect}" end rodauth.handle_internal_request(:"_handle_#{route}") end end Feature.define(:internal_request, :InternalRequest) do configuration_module_eval do def internal_request_configuration(&block) @auth.instance_exec do (@internal_request_configuration_blocks ||= []) << block end end end def post_configure super return if is_a?(InternalRequestMethods) superklasses = [] superklass = self.class until superklass == Rodauth::Auth superklasses << superklass superklass = superklass.superclass end klass = self.class internal_class = Class.new(klass) internal_class.instance_variable_set(:@configuration_name, klass.configuration_name) configuration = internal_class.configuration superklasses.reverse_each do |superklass| if blocks = superklass.instance_variable_get(:@internal_request_configuration_blocks) blocks.each do |block| configuration.instance_exec(&block) end end end internal_class.send(:extend, InternalRequestClassMethods) internal_class.send(:include, InternalRequestMethods) internal_class.allocate.post_configure ([:base] + internal_class.features).each do |feature_name| feature = FEATURES[feature_name] if meths = feature.internal_request_methods meths.each do |name| klass.define_singleton_method(name){|opts={}, &block| internal_class.internal_request(name, opts, &block)} end end end klass.const_set(:InternalRequest, internal_class) klass.private_constant :InternalRequest end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/json.rb000066400000000000000000000166501515725514200234040ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:json, :Json) do translatable_method :json_not_accepted_error_message, 'Unsupported Accept header. Must accept "application/json" or compatible content type' translatable_method :json_non_post_error_message, 'non-POST method used in JSON API' auth_value_method :json_accept_regexp, /(?:(?:\*|\bapplication)\/\*|\bapplication\/(?:vnd\.api\+)?json\b)/i auth_value_method :json_check_accept?, true auth_value_method :json_request_content_type_regexp, /\bapplication\/(?:vnd\.api\+)?json\b/i auth_value_method :json_response_content_type, 'application/json' auth_value_method :json_response_custom_error_status?, true auth_value_method :json_response_error_status, 400 auth_value_method :json_response_error_key, "error" auth_value_method :json_response_field_error_key, "field-error" auth_value_method :json_response_success_key, "success" translatable_method :non_json_request_error_message, 'Only JSON format requests are allowed' auth_value_methods( :only_json?, :use_json?, ) auth_methods( :json_request?, :json_response_error? ) auth_private_methods :json_response_body def set_field_error(field, message) return super unless use_json? json_response[json_response_field_error_key] = [field, message] end def set_error_flash(message) return super unless use_json? json_response[json_response_error_key] = message end def set_redirect_error_flash(message) return super unless use_json? json_response[json_response_error_key] = message end def set_notice_flash(message) return super unless use_json? json_response[json_response_success_key] = message if include_success_messages? end def set_notice_now_flash(message) return super unless use_json? json_response[json_response_success_key] = message if include_success_messages? end def json_request? return @json_request if defined?(@json_request) @json_request = request.content_type =~ json_request_content_type_regexp end def use_json? json_request? || only_json? end def view(page, title) return super unless use_json? return_json_response end def json_response_error? !!json_response[json_response_error_key] end private def check_csrf? return false if use_json? super end def _set_otp_unlock_info if use_json? json_response[:num_successes] = otp_unlock_num_successes json_response[:required_successes] = otp_unlock_auths_required json_response[:next_attempt_after] = otp_unlock_next_auth_attempt_after.to_i end end def after_otp_unlock_auth_success super if defined?(super) if otp_locked_out? _set_otp_unlock_info json_response[:deadline] = otp_unlock_deadline.to_i end end def after_otp_unlock_auth_failure super if defined?(super) _set_otp_unlock_info end def after_otp_unlock_not_yet_available super if defined?(super) _set_otp_unlock_info end def before_two_factor_manage_route super if defined?(super) if use_json? json_response[:setup_links] = two_factor_setup_links.sort.map{|_,link| link} json_response[:remove_links] = two_factor_remove_links.sort.map{|_,link| link} json_response[json_response_success_key] ||= "" if include_success_messages? return_json_response end end def before_two_factor_auth_route super if defined?(super) if use_json? json_response[:auth_links] = two_factor_auth_links.sort.map{|_,link| link} json_response[json_response_success_key] ||= "" if include_success_messages? return_json_response end end def before_view_recovery_codes super if defined?(super) if use_json? json_response[:codes] = recovery_codes json_response[json_response_success_key] ||= "" if include_success_messages? end end def before_webauthn_setup_route super if defined?(super) if use_json? && !param_or_nil(webauthn_setup_param) cred = new_webauthn_credential json_response[webauthn_setup_param] = cred.as_json json_response[webauthn_setup_challenge_param] = cred.challenge json_response[webauthn_setup_challenge_hmac_param] = compute_hmac(cred.challenge) end end def before_webauthn_auth_route super if defined?(super) if use_json? && !param_or_nil(webauthn_auth_param) cred = webauthn_credential_options_for_get json_response[webauthn_auth_param] = cred.as_json json_response[webauthn_auth_challenge_param] = cred.challenge json_response[webauthn_auth_challenge_hmac_param] = compute_hmac(cred.challenge) end end def before_webauthn_login_route super if defined?(super) if use_json? && !param_or_nil(webauthn_auth_param) && webauthn_login_options? cred = webauthn_credential_options_for_get json_response[webauthn_auth_param] = cred.as_json json_response[webauthn_auth_challenge_param] = cred.challenge json_response[webauthn_auth_challenge_hmac_param] = compute_hmac(cred.challenge) end end def before_webauthn_remove_route super if defined?(super) if use_json? && !param_or_nil(webauthn_remove_param) json_response[webauthn_remove_param] = account_webauthn_usage end end def before_otp_setup_route super if defined?(super) if use_json? && otp_keys_use_hmac? && !param_or_nil(otp_setup_raw_param) _otp_tmp_key(otp_new_secret) json_response[otp_setup_param] = otp_user_key json_response[otp_setup_raw_param] = otp_key end end def before_rodauth if json_request? if json_check_accept? && (accept = request.env['HTTP_ACCEPT']) && accept !~ json_accept_regexp response.status = 406 json_response[json_response_error_key] = json_not_accepted_error_message _return_json_response end unless request.post? response.status = 405 set_response_header('allow', 'POST') json_response[json_response_error_key] = json_non_post_error_message return_json_response end elsif only_json? response.status = json_response_error_status return_response non_json_request_error_message end super end def redirect(_) return super unless use_json? return_json_response end def return_json_response _return_json_response end def _return_json_response response.status ||= json_response_error_status if json_response_error? response.headers[convert_response_header_key('content-type')] ||= json_response_content_type return_response _json_response_body(json_response) end def include_success_messages? !json_response_success_key.nil? end def _json_response_body(hash) request.send(:convert_to_json, hash) end def json_response @json_response ||= {} end def set_redirect_error_status(status) if use_json? && json_response_custom_error_status? response.status = status end end def set_response_error_status(status) if use_json? && !json_response_custom_error_status? status = json_response_error_status end super end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/jwt.rb000066400000000000000000000066501515725514200232360ustar00rootroot00000000000000# frozen-string-literal: true require 'jwt' require 'jwt/version' module Rodauth Feature.define(:jwt, :Jwt) do depends :json translatable_method :invalid_jwt_format_error_message, "invalid JWT format or claim in Authorization header" auth_value_method :jwt_algorithm, "HS256" auth_value_method :jwt_authorization_ignore, /\A(?:Basic|Digest) / auth_value_method :jwt_authorization_remove, /\ABearer:?\s+/ auth_value_method :jwt_decode_opts, {}.freeze auth_value_method :jwt_old_secret, nil auth_value_method :jwt_session_key, nil auth_value_method :jwt_symbolize_deeply?, false auth_value_methods( :jwt_secret, :use_jwt? ) auth_methods( :jwt_session_hash, :jwt_token, :session_jwt, :set_jwt_token ) def_deprecated_alias :json_check_accept?, :jwt_check_accept? def session return @session if defined?(@session) return super unless use_jwt? s = {} if jwt_token unless session_data = jwt_payload json_response[json_response_error_key] ||= invalid_jwt_format_error_message _return_json_response end if jwt_session_key session_data = session_data[jwt_session_key] end if session_data if jwt_symbolize_deeply? s = JSON.parse(JSON.generate(session_data), :symbolize_names=>true) elsif scope.opts[:sessions_convert_symbols] s = session_data else session_data.each{|k,v| s[k.to_sym] = v} end end end @session = s end def clear_session super set_jwt if use_jwt? end def jwt_secret raise ConfigurationError, "jwt_secret not set" end def jwt_session_hash jwt_session_key ? {jwt_session_key=>session} : session end def session_jwt JWT.encode(jwt_session_hash, jwt_secret, jwt_algorithm) end def jwt_token return @jwt_token if defined?(@jwt_token) if (v = request.env['HTTP_AUTHORIZATION']) && v !~ jwt_authorization_ignore @jwt_token = v.sub(jwt_authorization_remove, '') end end def set_jwt_token(token) set_response_header('authorization', token) end def use_jwt? use_json? end def use_json? jwt_token || super end def valid_jwt? !!(jwt_token && jwt_payload) end private def _jwt_decode_opts jwt_decode_opts end if JWT.gem_version >= Gem::Version.new("2.4") def _jwt_decode_secrets secrets = [jwt_secret, jwt_old_secret] secrets.compact! secrets end # :nocov: else def _jwt_decode_secrets jwt_secret end # :nocov: end def jwt_payload return @jwt_payload if defined?(@jwt_payload) @jwt_payload = JWT.decode(jwt_token, _jwt_decode_secrets, true, _jwt_decode_opts.merge(:algorithm=>jwt_algorithm))[0] rescue JWT::DecodeError => e rescue_jwt_payload(e) end def rescue_jwt_payload(_) @jwt_payload = false end def set_session_value(key, value) super set_jwt if use_jwt? value end def remove_session_value(key) value = super set_jwt if use_jwt? value end def return_json_response set_jwt super end def set_jwt set_jwt_token(session_jwt) end def use_scope_clear_session? super && !use_jwt? end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/jwt_cors.rb000066400000000000000000000027431515725514200242630ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:jwt_cors, :JwtCors) do depends :jwt auth_value_method :jwt_cors_allow_origin, false auth_value_method :jwt_cors_allow_methods, 'POST' auth_value_method :jwt_cors_allow_headers, 'Content-Type, Authorization, Accept' auth_value_method :jwt_cors_expose_headers, 'Authorization' auth_value_method :jwt_cors_max_age, 86400 auth_methods(:jwt_cors_allow?) def jwt_cors_allow? return false unless origin = request.env['HTTP_ORIGIN'] case allowed = jwt_cors_allow_origin when String timing_safe_eql?(origin, allowed) when Array allowed.any?{|s| timing_safe_eql?(origin, s)} when Regexp allowed =~ origin when true true else false end end private def before_rodauth if jwt_cors_allow? set_response_header('access-control-allow-origin', request.env['HTTP_ORIGIN']) # Handle CORS preflight request if request.request_method == 'OPTIONS' set_response_header('access-control-allow-methods', jwt_cors_allow_methods) set_response_header('access-control-allow-headers', jwt_cors_allow_headers) set_response_header('access-control-max-age', jwt_cors_max_age.to_s) response.status = 204 return_response end set_response_header('access-control-expose-headers', jwt_cors_expose_headers) end super end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/jwt_refresh.rb000066400000000000000000000204421515725514200247470ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:jwt_refresh, :JwtRefresh) do depends :jwt after 'refresh_token' before 'refresh_token' auth_value_method :allow_refresh_with_expired_jwt_access_token?, false session_key :jwt_refresh_token_data_session_key, :jwt_refresh_token_data session_key :jwt_refresh_token_hmac_session_key, :jwt_refresh_token_hash auth_value_method :jwt_access_token_key, 'access_token' auth_value_method :jwt_access_token_not_before_period, 5 auth_value_method :jwt_access_token_period, 1800 translatable_method :jwt_refresh_invalid_token_message, 'invalid JWT refresh token' auth_value_method :jwt_refresh_token_account_id_column, :account_id auth_value_method :jwt_refresh_token_deadline_column, :deadline auth_value_method :jwt_refresh_token_deadline_interval, {:days=>14}.freeze auth_value_method :jwt_refresh_token_id_column, :id auth_value_method :jwt_refresh_token_key, 'refresh_token' auth_value_method :jwt_refresh_token_key_column, :key auth_value_method :jwt_refresh_token_key_param, 'refresh_token' auth_value_method :jwt_refresh_token_table, :account_jwt_refresh_keys translatable_method :jwt_refresh_without_access_token_message, 'no JWT access token provided during refresh' auth_value_method :jwt_refresh_without_access_token_status, 401 translatable_method :expired_jwt_access_token_message, "expired JWT access token" auth_value_method :expired_jwt_access_token_status, 400 auth_private_methods( :account_from_refresh_token ) route do |r| @jwt_refresh_route = true before_jwt_refresh_route r.post do if !session_value response.status ||= jwt_refresh_without_access_token_status json_response[json_response_error_key] = jwt_refresh_without_access_token_message elsif (refresh_token = param_or_nil(jwt_refresh_token_key_param)) && account_from_refresh_token(refresh_token) transaction do before_refresh_token formatted_token = generate_refresh_token remove_jwt_refresh_token_key(refresh_token) set_jwt_refresh_token_hmac_session_key(formatted_token) json_response[jwt_refresh_token_key] = formatted_token json_response[jwt_access_token_key] = session_jwt after_refresh_token end else json_response[json_response_error_key] = jwt_refresh_invalid_token_message end _return_json_response end end def update_session super # JWT login puts the access token in the header. # We put the refresh token in the body. # Note, do not put the access_token in the body here, as the access token content is not yet finalised. token = json_response[jwt_refresh_token_key] = generate_refresh_token set_jwt_refresh_token_hmac_session_key(token) end def set_jwt_token(token) super if json_response[json_response_error_key] json_response.delete(jwt_access_token_key) else json_response[jwt_access_token_key] = token end end def jwt_session_hash h = super t = Time.now.to_i h[convert_session_key(:exp)] = t + jwt_access_token_period h[convert_session_key(:iat)] = t h[convert_session_key(:nbf)] = t - jwt_access_token_not_before_period h end def account_from_refresh_token(token) @account = _account_from_refresh_token(token) end def clear_tokens(reason) super jwt_refresh_token_account_ds(account_id).delete unless logged_in? end private def rescue_jwt_payload(e) if e.instance_of?(JWT::ExpiredSignature) begin # Some versions of jwt will raise JWT::ExpiredSignature even when the # JWT is invalid for other reasons. Make sure the expiration is the # only reason the JWT isn't valid before treating this as an expired token. JWT.decode(jwt_token, jwt_secret, true, Hash[jwt_decode_opts].merge!(:verify_expiration=>false, :algorithm=>jwt_algorithm))[0] rescue else json_response[json_response_error_key] = expired_jwt_access_token_message response.status ||= expired_jwt_access_token_status end end super end def _account_from_refresh_token(token) id, token_id, key = _account_refresh_token_split(token) unless key && (id.to_s == session_value.to_s) && (actual = get_active_refresh_token(id, token_id)) && (timing_safe_eql?(key, convert_token_key(actual)) || (hmac_secret_rotation? && timing_safe_eql?(key, compute_old_hmac(actual)))) && jwt_refresh_token_match?(key) return end ds = account_ds(id) ds = ds.where(account_session_status_filter) unless skip_status_checks? ds.first end def _account_refresh_token_split(token) id, token = split_token(token) id = convert_token_id(id) return unless id && token token_id, key = split_token(token) token_id = convert_token_id(token_id) return unless token_id && key [id, token_id, key] end def _jwt_decode_opts if allow_refresh_with_expired_jwt_access_token? && (@jwt_refresh_route || request.path == jwt_refresh_path) Hash[super].merge!(:verify_expiration=>false) else super end end def jwt_refresh_token_match?(key) # We don't need to match tokens if we are requiring a valid current access token return true unless allow_refresh_with_expired_jwt_access_token? # If allowing with expired jwt access token, check the expired session contains # hmac matching submitted and active refresh token. s = session[jwt_refresh_token_hmac_session_key].to_s h = session[jwt_refresh_token_data_session_key].to_s + key timing_safe_eql?(compute_hmac(h), s) || (hmac_secret_rotation? && timing_safe_eql?(compute_old_hmac(h), s)) end def get_active_refresh_token(account_id, token_id) jwt_refresh_token_account_ds(account_id). where(Sequel::CURRENT_TIMESTAMP > jwt_refresh_token_deadline_column). delete jwt_refresh_token_account_token_ds(account_id, token_id). get(jwt_refresh_token_key_column) end def jwt_refresh_token_account_ds(account_id) jwt_refresh_token_ds.where(jwt_refresh_token_account_id_column => account_id) end def jwt_refresh_token_account_token_ds(account_id, token_id) jwt_refresh_token_account_ds(account_id). where(jwt_refresh_token_id_column=>token_id) end def jwt_refresh_token_ds db[jwt_refresh_token_table] end def remove_jwt_refresh_token_key(token) account_id, token_id, _ = _account_refresh_token_split(token) jwt_refresh_token_account_token_ds(account_id, token_id).delete end def generate_refresh_token hash = jwt_refresh_token_insert_hash [account_id, jwt_refresh_token_ds.insert(hash), convert_token_key(hash[jwt_refresh_token_key_column])].join(token_separator) end def jwt_refresh_token_insert_hash hash = {jwt_refresh_token_account_id_column => account_id, jwt_refresh_token_key_column => random_key} set_deadline_value(hash, jwt_refresh_token_deadline_column, jwt_refresh_token_deadline_interval) hash end def set_jwt_refresh_token_hmac_session_key(token) if allow_refresh_with_expired_jwt_access_token? key = _account_refresh_token_split(token).last data = random_key set_session_value(jwt_refresh_token_data_session_key, data) set_session_value(jwt_refresh_token_hmac_session_key, compute_hmac(data + key)) end end def before_logout if token = param_or_nil(jwt_refresh_token_key_param) if token == 'all' jwt_refresh_token_account_ds(session_value).delete else id, token_id, key = _account_refresh_token_split(token) if id && token_id && key && (actual = get_active_refresh_token(session_value, token_id)) && timing_safe_eql?(key, convert_token_key(actual)) jwt_refresh_token_account_token_ds(id, token_id).delete end end end super if defined?(super) end def after_close_account jwt_refresh_token_account_ds(account_id).delete super if defined?(super) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/lockout.rb000066400000000000000000000253151515725514200241110ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:lockout, :Lockout) do depends :login, :email_base loaded_templates %w'unlock-account-request unlock-account password-field unlock-account-email' view 'unlock-account-request', 'Request Account Unlock', 'unlock_account_request' view 'unlock-account', 'Unlock Account', 'unlock_account' before 'unlock_account' before 'unlock_account_request' after 'unlock_account' after 'unlock_account_request' after 'account_lockout' additional_form_tags 'unlock_account' additional_form_tags 'unlock_account_request' button 'Unlock Account', 'unlock_account' button 'Request Account Unlock', 'unlock_account_request' error_flash "There was an error unlocking your account", 'unlock_account' error_flash "This account is currently locked out and cannot be logged in to", "login_lockout" error_flash "An email has recently been sent to you with a link to unlock the account", 'unlock_account_email_recently_sent' error_flash "There was an error unlocking your account: invalid or expired unlock account key", 'no_matching_unlock_account_key' notice_flash "Your account has been unlocked", 'unlock_account' notice_flash "An email has been sent to you with a link to unlock your account", 'unlock_account_request' redirect :unlock_account response :unlock_account response :unlock_account_request redirect(:unlock_account_request){default_post_email_redirect} redirect(:unlock_account_email_recently_sent){default_post_email_redirect} email :unlock_account, 'Unlock Account' auth_value_method :unlock_account_autologin?, true auth_value_method :max_invalid_logins, 100 auth_value_method :account_login_failures_table, :account_login_failures auth_value_method :account_login_failures_id_column, :id auth_value_method :account_login_failures_number_column, :number auth_value_method :account_lockouts_table, :account_lockouts auth_value_method :account_lockouts_id_column, :id auth_value_method :account_lockouts_key_column, :key auth_value_method :account_lockouts_email_last_sent_column, :email_last_sent auth_value_method :account_lockouts_deadline_column, :deadline auth_value_method :account_lockouts_deadline_interval, {:days=>1}.freeze translatable_method :unlock_account_explanatory_text, '

This account is currently locked out. You can unlock the account:

' translatable_method :unlock_account_request_explanatory_text, '

This account is currently locked out. You can request that the account be unlocked:

' auth_value_method :unlock_account_key_param, 'key' auth_value_method :unlock_account_requires_password?, false auth_value_method :unlock_account_skip_resend_email_within, 300 session_key :unlock_account_session_key, :unlock_account_key auth_methods( :clear_invalid_login_attempts, :generate_unlock_account_key, :get_unlock_account_key, :get_unlock_account_email_last_sent, :invalid_login_attempted, :locked_out?, :set_unlock_account_email_last_sent, :unlock_account_email_link, :unlock_account, :unlock_account_key ) auth_private_methods :account_from_unlock_key internal_request_method(:lock_account) internal_request_method(:unlock_account_request) internal_request_method(:unlock_account) route(:unlock_account_request) do |r| check_already_logged_in before_unlock_account_request_route r.post do if account_from_login(login_param_value) && get_unlock_account_key if unlock_account_email_recently_sent? set_redirect_error_flash unlock_account_email_recently_sent_error_flash redirect unlock_account_email_recently_sent_redirect end @unlock_account_key_value = get_unlock_account_key transaction do before_unlock_account_request set_unlock_account_email_last_sent send_unlock_account_email after_unlock_account_request end unlock_account_request_response else set_redirect_error_status(no_matching_login_error_status) set_error_reason :no_matching_login set_redirect_error_flash no_matching_login_message.to_s.capitalize redirect unlock_account_request_redirect end end end route(:unlock_account) do |r| check_already_logged_in before_unlock_account_route r.get do if key = param_or_nil(unlock_account_key_param) set_session_value(unlock_account_session_key, key) redirect(r.path) end if (key = session[unlock_account_session_key]) && account_from_unlock_key(key) unlock_account_view else remove_session_value(unlock_account_session_key) set_redirect_error_flash no_matching_unlock_account_key_error_flash redirect require_login_redirect end end r.post do key = session[unlock_account_session_key] || param(unlock_account_key_param) unless account_from_unlock_key(key) set_redirect_error_status invalid_key_error_status set_error_reason :invalid_unlock_account_key set_redirect_error_flash no_matching_unlock_account_key_error_flash redirect unlock_account_request_redirect end if !unlock_account_requires_password? || password_match?(param(password_param)) transaction do before_unlock_account unlock_account clear_tokens(:unlock_account) after_unlock_account if unlock_account_autologin? autologin_session('unlock_account') end end remove_session_value(unlock_account_session_key) unlock_account_response else set_response_error_reason_status(:invalid_password, invalid_password_error_status) set_field_error(password_param, invalid_password_message) set_error_flash unlock_account_error_flash unlock_account_view end end end def locked_out? if t = convert_timestamp(account_lockouts_ds.get(account_lockouts_deadline_column)) if Time.now < t true else unlock_account false end else false end end def unlock_account transaction do remove_lockout_metadata end end def clear_invalid_login_attempts unlock_account end def _setup_account_lockouts_hash(account_id, key) hash = {account_lockouts_id_column=>account_id, account_lockouts_key_column=>key} set_deadline_value(hash, account_lockouts_deadline_column, account_lockouts_deadline_interval) hash end def invalid_login_attempted ds = account_login_failures_ds. where(account_login_failures_id_column=>account_id) number = if db.database_type == :postgres ds.returning(account_login_failures_number_column). with_sql(:update_sql, account_login_failures_number_column=>Sequel.expr(account_login_failures_number_column)+1). single_value else # :nocov: if ds.update(account_login_failures_number_column=>Sequel.expr(account_login_failures_number_column)+1) > 0 ds.get(account_login_failures_number_column) end # :nocov: end unless number # Ignoring the violation is safe here. It may allow slightly more than max_invalid_logins invalid logins before # lockout, but allowing a few extra is OK if the race is lost. ignore_uniqueness_violation{account_login_failures_ds.insert(account_login_failures_id_column=>account_id)} number = 1 end if number >= max_invalid_logins @unlock_account_key_value = generate_unlock_account_key hash = _setup_account_lockouts_hash(account_id, unlock_account_key_value) if e = raised_uniqueness_violation{account_lockouts_ds.insert(hash)} # If inserting into the lockout table raises a violation, we should just be able to pull the already inserted # key out of it. If that doesn't return a valid key, we should reraise the error. raise e unless @unlock_account_key_value = account_lockouts_ds.get(account_lockouts_key_column) after_account_lockout show_lockout_page else after_account_lockout e end end end def get_unlock_account_key account_lockouts_ds.get(account_lockouts_key_column) end def account_from_unlock_key(key) @account = _account_from_unlock_key(key) end def unlock_account_email_link token_link(unlock_account_route, unlock_account_key_param, unlock_account_key_value) end def get_unlock_account_email_last_sent if column = account_lockouts_email_last_sent_column if ts = account_lockouts_ds.get(column) convert_timestamp(ts) end end end def set_unlock_account_email_last_sent account_lockouts_ds.update(account_lockouts_email_last_sent_column=>Sequel::CURRENT_TIMESTAMP) if account_lockouts_email_last_sent_column end def unlock_account_email_recently_sent? (email_last_sent = get_unlock_account_email_last_sent) && (Time.now - email_last_sent < unlock_account_skip_resend_email_within) end def clear_tokens(reason) super account_lockouts_ds.update(account_lockouts_key_column => generate_unlock_account_key) end private attr_reader :unlock_account_key_value def before_login_attempt if locked_out? show_lockout_page end super end def after_login clear_invalid_login_attempts super end def after_login_failure invalid_login_attempted super end def after_close_account remove_lockout_metadata super if defined?(super) end def generate_unlock_account_key random_key end def remove_lockout_metadata account_login_failures_ds.delete account_lockouts_ds.delete end def show_lockout_page set_response_error_reason_status(:account_locked_out, lockout_error_status) set_error_flash login_lockout_error_flash return_response unlock_account_request_view end def use_date_arithmetic? super || db.database_type == :mysql end def account_login_failures_ds db[account_login_failures_table].where(account_login_failures_id_column=>account_id) end def account_lockouts_ds(id=account_id) db[account_lockouts_table].where(account_lockouts_id_column=>id) end def _account_from_unlock_key(token) account_from_key(token){|id| account_lockouts_ds(id).get(account_lockouts_key_column)} end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/login.rb000066400000000000000000000114301515725514200235320ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:login, :Login) do notice_flash "You have been logged in" notice_flash "Login recognized, please enter your password", "need_password" error_flash "There was an error logging in" loaded_templates %w'login login-form login-form-footer multi-phase-login login-field password-field login-display' view 'login', 'Login' view 'multi-phase-login', 'Login', 'multi_phase_login' additional_form_tags button 'Login' redirect auth_value_method :login_error_status, 401 translatable_method :login_form_footer_links_heading, '' auth_value_method :login_return_to_requested_location?, false auth_value_method :login_return_to_requested_location_max_path_size, 2048 auth_value_method :use_multi_phase_login?, false session_key :login_redirect_session_key, :login_redirect auth_cached_method :multi_phase_login_forms auth_cached_method :login_form_footer auth_value_methods :login_return_to_requested_location_path auth_private_methods( :login_form_footer_links, :login_response ) internal_request_method internal_request_method :valid_login_and_password? route do |r| check_already_logged_in before_login_route r.get do login_view end r.post do skip_error_flash = false view = :login_view catch_error do unless account_from_login(login_param_value) throw_error_reason(:no_matching_login, no_matching_login_error_status, login_param, no_matching_login_message) end before_login_attempt unless open_account? throw_error_reason(:unverified_account, unopen_account_error_status, login_param, unverified_account_message) end if use_multi_phase_login? @valid_login_entered = true view = :multi_phase_login_view unless param_or_nil(password_param) after_login_entered_during_multi_phase_login skip_error_flash = true next end end unless password_match?(param(password_param)) after_login_failure throw_error_reason(:invalid_password, login_error_status, password_param, invalid_password_message) end login('password') end set_error_flash login_error_flash unless skip_error_flash send(view) end end attr_reader :login_form_header attr_reader :saved_login_redirect private :saved_login_redirect def login(auth_type) @saved_login_redirect = remove_session_value(login_redirect_session_key) transaction do before_login login_session(auth_type) yield if block_given? after_login end require_response(:_login_response) end def login_required if login_return_to_requested_location? && (path = login_return_to_requested_location_path) && path.bytesize <= login_return_to_requested_location_max_path_size set_session_value(login_redirect_session_key, path) end super end def login_return_to_requested_location_path request.fullpath if request.get? end def after_login_entered_during_multi_phase_login set_notice_now_flash need_password_notice_flash if multi_phase_login_forms.length == 1 && (meth = multi_phase_login_forms[0][2]) send(meth) end end def skip_login_field_on_login? return false unless use_multi_phase_login? valid_login_entered? end def skip_password_field_on_login? return false unless use_multi_phase_login? !valid_login_entered? end def valid_login_entered? @valid_login_entered end def login_hidden_field "" end def login_form_footer_links @login_form_footer_links ||= _filter_links(_login_form_footer_links) end def render_multi_phase_login_forms multi_phase_login_forms.sort.map{|_, form, _| form}.join("\n") end def require_login_redirect login_path end private def _login_response set_notice_flash login_notice_flash redirect(saved_login_redirect || login_redirect) end def _login_form_footer_links [] end def _multi_phase_login_forms forms = [] forms << [10, render("login-form"), nil] if has_password? forms end def _login_form_footer return '' if _login_form_footer_links.empty? render('login-form-footer') end def _login(auth_type) warn("Deprecated #_login method called, use #login instead.") login(auth_type) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/login_password_requirements_base.rb000066400000000000000000000154521515725514200312610ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:login_password_requirements_base, :LoginPasswordRequirementsBase) do translatable_method :already_an_account_with_this_login_message, 'already an account with this login' auth_value_method :login_confirm_param, 'login-confirm' auth_value_method :login_email_regexp, /\A[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+\z/ auth_value_method :login_minimum_length, 3 auth_value_method :login_maximum_length, 255 auth_value_method :login_maximum_bytes, 255 translatable_method :login_not_valid_email_message, 'not a valid email address' translatable_method :logins_do_not_match_message, 'logins do not match' auth_value_method :password_confirm_param, 'password-confirm' auth_value_method :password_minimum_length, 6 auth_value_method :password_maximum_bytes, nil auth_value_method :password_maximum_length, nil translatable_method :passwords_do_not_match_message, 'passwords do not match' auth_value_method :require_email_address_logins?, true auth_value_method :require_login_confirmation?, true auth_value_method :require_password_confirmation?, true translatable_method :same_as_existing_password_message, "invalid password, same as current password" translatable_method :contains_null_byte_message, 'contains null byte' auth_value_methods( :login_confirm_label, :login_does_not_meet_requirements_message, :login_too_long_message, :login_too_many_bytes_message, :login_too_short_message, :password_confirm_label, :password_does_not_meet_requirements_message, :password_hash_cost, :password_too_long_message, :password_too_many_bytes_message, :password_too_short_message ) auth_methods( :login_confirmation_matches?, :login_meets_requirements?, :login_valid_email?, :password_hash, :password_meets_requirements?, :set_password ) def login_confirm_label "Confirm #{login_label}" end def password_confirm_label "Confirm #{password_label}" end def login_meets_requirements?(login) login_meets_length_requirements?(login) && \ login_meets_email_requirements?(login) end def password_meets_requirements?(password) password_meets_length_requirements?(password) && \ password_does_not_contain_null_byte?(password) end def set_password(password) hash = password_hash(password) if account_password_hash_column update_account(account_password_hash_column=>hash) elsif password_hash_ds.update(password_hash_column=>hash) == 0 # This shouldn't raise a uniqueness error, as the update should only fail for a new user, # and an existing user should always have a valid password hash row. If this does # fail, retrying it will cause problems, it will override a concurrently running update # with potentially a different password. db[password_hash_table].insert(password_hash_id_column=>account_id, password_hash_column=>hash) end hash end def password_hash(password) BCrypt::Password.create(password, :cost=>password_hash_cost) end private attr_reader :login_requirement_message attr_reader :password_requirement_message def password_does_not_meet_requirements_message "invalid password, does not meet requirements#{" (#{password_requirement_message})" if password_requirement_message}" end def password_too_long_message "maximum #{password_maximum_length} characters" end def password_too_many_bytes_message "maximum #{password_maximum_bytes} bytes" end def password_too_short_message "minimum #{password_minimum_length} characters" end def set_password_requirement_error_message(reason, message) set_error_reason(reason) @password_requirement_message = message end def login_does_not_meet_requirements_message "invalid login#{", #{login_requirement_message}" if login_requirement_message}" end def login_too_long_message "maximum #{login_maximum_length} characters" end def login_too_many_bytes_message "maximum #{login_maximum_bytes} bytes" end def login_too_short_message "minimum #{login_minimum_length} characters" end def set_login_requirement_error_message(reason, message) set_error_reason(reason) @login_requirement_message = message end if RUBY_VERSION >= '2.4' def login_confirmation_matches?(login, login_confirmation) login.casecmp?(login_confirmation) end # :nocov: else def login_confirmation_matches?(login, login_confirmation) login.casecmp(login_confirmation) == 0 end # :nocov: end def login_meets_length_requirements?(login) if login_minimum_length > login.length set_login_requirement_error_message(:login_too_short, login_too_short_message) false elsif login_maximum_length < login.length set_login_requirement_error_message(:login_too_long, login_too_long_message) false elsif login_maximum_bytes < login.bytesize set_login_requirement_error_message(:login_too_many_bytes, login_too_many_bytes_message) false else true end end def login_meets_email_requirements?(login) return true unless require_email_address_logins? return true if login_valid_email?(login) set_login_requirement_error_message(:login_not_valid_email, login_not_valid_email_message) return false end def login_valid_email?(login) login =~ login_email_regexp end def password_meets_length_requirements?(password) if password_minimum_length > password.length set_password_requirement_error_message(:password_too_short, password_too_short_message) false elsif password_maximum_length && password_maximum_length < password.length set_password_requirement_error_message(:password_too_long, password_too_long_message) false elsif password_maximum_bytes && password_maximum_bytes < password.bytesize set_password_requirement_error_message(:password_too_many_bytes, password_too_many_bytes_message) false else true end end def password_does_not_contain_null_byte?(password) return true unless password.include?("\0") set_password_requirement_error_message(:password_contains_null_byte, contains_null_byte_message) false end if ENV['RACK_ENV'] == 'test' def password_hash_cost BCrypt::Engine::MIN_COST end else # :nocov: def password_hash_cost BCrypt::Engine::DEFAULT_COST end # :nocov: end def extract_password_hash_cost(hash) hash[4, 2].to_i end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/logout.rb000066400000000000000000000011701515725514200237330ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:logout, :Logout) do notice_flash "You have been logged out" loaded_templates %w'logout' view 'logout', 'Logout' additional_form_tags before after button 'Logout' redirect{require_login_redirect} response auth_methods :logout route do |r| before_logout_route r.get do logout_view end r.post do transaction do before_logout logout after_logout end logout_response end end def logout clear_session end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/otp.rb000066400000000000000000000307011515725514200232260ustar00rootroot00000000000000# frozen-string-literal: true require 'rotp' require 'rqrcode' module Rodauth Feature.define(:otp, :Otp) do depends :two_factor_base additional_form_tags 'otp_disable' additional_form_tags 'otp_auth' additional_form_tags 'otp_setup' after 'otp_authentication_failure' after 'otp_disable' after 'otp_setup' before 'otp_authentication' before 'otp_setup' before 'otp_disable' button 'Authenticate Using TOTP', 'otp_auth' button 'Disable TOTP Authentication', 'otp_disable' button 'Setup TOTP Authentication', 'otp_setup' error_flash "Error disabling TOTP authentication", 'otp_disable' error_flash "Error logging in via TOTP authentication", 'otp_auth' error_flash "Error setting up TOTP authentication", 'otp_setup' error_flash "You have already setup TOTP authentication", 'otp_already_setup' error_flash "TOTP authentication code use locked out due to numerous failures", 'otp_lockout' notice_flash "TOTP authentication has been disabled", 'otp_disable' notice_flash "TOTP authentication is now setup", 'otp_setup' redirect :otp_disable redirect :otp_already_setup redirect :otp_setup response :otp_disable response :otp_setup redirect(:otp_lockout){two_factor_auth_required_redirect} loaded_templates %w'otp-disable otp-auth otp-setup otp-auth-code-field password-field' view 'otp-disable', 'Disable TOTP Authentication', 'otp_disable' view 'otp-auth', 'Enter Authentication Code', 'otp_auth' view 'otp-setup', 'Setup TOTP Authentication', 'otp_setup' translatable_method :otp_auth_link_text, "Authenticate Using TOTP" translatable_method :otp_setup_link_text, "Setup TOTP Authentication" translatable_method :otp_disable_link_text, "Disable TOTP Authentication" auth_value_method :otp_auth_failures_limit, 5 translatable_method :otp_auth_label, 'Authentication Code' auth_value_method :otp_auth_param, 'otp' auth_value_method :otp_class, ROTP::TOTP auth_value_method :otp_digits, nil auth_value_method :otp_drift, 30 auth_value_method :otp_interval, nil translatable_method :otp_invalid_auth_code_message, "Invalid authentication code" translatable_method :otp_invalid_secret_message, "invalid secret" auth_value_method :otp_keys_column, :key auth_value_method :otp_keys_id_column, :id auth_value_method :otp_keys_failures_column, :num_failures auth_value_method :otp_keys_table, :account_otp_keys auth_value_method :otp_keys_last_use_column, :last_use translatable_method :otp_provisioning_uri_label, 'Provisioning URL' translatable_method :otp_secret_label, 'Secret' auth_value_method :otp_setup_param, 'otp_secret' auth_value_method :otp_setup_raw_param, 'otp_raw_secret' translatable_method :otp_auth_form_footer, '' auth_cached_method :otp_key auth_cached_method :otp private :otp auth_value_methods( :otp_issuer, :otp_keys_use_hmac? ) auth_methods( :otp_available?, :otp_exists?, :otp_last_use, :otp_locked_out?, :otp_new_secret, :otp_provisioning_name, :otp_provisioning_uri, :otp_qr_code, :otp_record_authentication_failure, :otp_remove, :otp_remove_auth_failures, :otp_update_last_use, :otp_valid_code?, :otp_valid_key? ) auth_private_methods( :otp_add_key, :otp_tmp_key, :otp_valid_code_for_old_secret ) internal_request_method :otp_setup_params internal_request_method :otp_setup internal_request_method :otp_auth internal_request_method :valid_otp_auth? internal_request_method :otp_disable route(:otp_auth) do |r| require_login require_account_session require_two_factor_not_authenticated('totp') require_otp_setup if otp_locked_out? set_response_error_reason_status(:otp_locked_out, lockout_error_status) set_redirect_error_flash otp_lockout_error_flash redirect otp_lockout_redirect end before_otp_auth_route r.get do otp_auth_view end r.post do if otp_valid_code?(param(otp_auth_param)) && otp_update_last_use before_otp_authentication two_factor_authenticate('totp') end otp_record_authentication_failure after_otp_authentication_failure set_response_error_reason_status(:invalid_otp_auth_code, invalid_key_error_status) set_field_error(otp_auth_param, otp_invalid_auth_code_message) set_error_flash otp_auth_error_flash otp_auth_view end end route(:otp_setup) do |r| require_account if otp_exists? set_redirect_error_flash otp_already_setup_error_flash redirect otp_already_setup_redirect end before_otp_setup_route r.get do otp_tmp_key(otp_new_secret) otp_setup_view end r.post do secret = param(otp_setup_param) catch_error do unless otp_valid_key?(secret) otp_tmp_key(otp_new_secret) throw_error_reason(:invalid_otp_secret, invalid_field_error_status, otp_setup_param, otp_invalid_secret_message) end if otp_keys_use_hmac? otp_tmp_key(param(otp_setup_raw_param)) else otp_tmp_key(secret) end unless two_factor_password_match?(param(password_param)) throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message) end unless otp_valid_code?(param(otp_auth_param)) throw_error_reason(:invalid_otp_auth_code, invalid_key_error_status, otp_auth_param, otp_invalid_auth_code_message) end transaction do before_otp_setup otp_add_key unless two_factor_authenticated? two_factor_update_session('totp') end after_otp_setup end otp_setup_response end set_error_flash otp_setup_error_flash otp_setup_view end end route(:otp_disable) do |r| require_account require_otp_setup before_otp_disable_route r.get do otp_disable_view end r.post do if two_factor_password_match?(param(password_param)) transaction do before_otp_disable otp_remove if two_factor_login_type_match?('totp') two_factor_remove_session('totp') end after_otp_disable end otp_disable_response end set_response_error_reason_status(:invalid_password, invalid_password_error_status) set_field_error(password_param, invalid_password_message) set_error_flash otp_disable_error_flash otp_disable_view end end def two_factor_remove super otp_remove end def two_factor_remove_auth_failures super otp_remove_auth_failures end def require_otp_setup unless otp_exists? set_redirect_error_status(two_factor_not_setup_error_status) set_error_reason :two_factor_not_setup set_redirect_error_flash two_factor_not_setup_error_flash redirect two_factor_need_setup_redirect end end def otp_available? otp_exists? && !otp_locked_out? end def otp_exists? !otp_key.nil? end def otp_valid_code?(ot_pass) if _otp_valid_code?(ot_pass, otp) true elsif hmac_secret_rotation? && _otp_valid_code?(ot_pass, _otp_for_key(otp_hmac_old_secret(otp_key))) _otp_valid_code_for_old_secret true else false end end def _otp_valid_code?(ot_pass, otp) return false unless otp_exists? ot_pass = ot_pass.gsub(/\s+/, '') if drift = otp_drift if otp.respond_to?(:verify_with_drift) # :nocov: otp.verify_with_drift(ot_pass, drift) # :nocov: else otp.verify(ot_pass, :drift_behind=>drift, :drift_ahead=>drift) end else otp.verify(ot_pass) end end def otp_remove otp_key_ds.delete @otp_key = nil end def otp_add_key _otp_add_key(otp_key) super if defined?(super) end def otp_update_last_use otp_key_ds. where(Sequel.date_add(otp_keys_last_use_column, :seconds=>_otp_interval) < Sequel::CURRENT_TIMESTAMP). update(otp_keys_last_use_column=>Sequel::CURRENT_TIMESTAMP) == 1 end def otp_last_use convert_timestamp(otp_key_ds.get(otp_keys_last_use_column)) end def otp_record_authentication_failure otp_key_ds.update(otp_keys_failures_column=>Sequel.identifier(otp_keys_failures_column) + 1) end def otp_remove_auth_failures otp_key_ds.update(otp_keys_failures_column=>0) end def otp_locked_out? otp_key_ds.get(otp_keys_failures_column) >= otp_auth_failures_limit end def otp_provisioning_uri otp.provisioning_uri(otp_provisioning_name) end def otp_issuer domain end def otp_provisioning_name account[login_column] end def otp_qr_code svg = RQRCode::QRCode.new(otp_provisioning_uri).as_svg(:module_size=>8, :viewbox=>true, :use_path=>true, :fill=>"fff") svg.sub(/\A<\?xml version="1\.0" standalone="yes"\?>/, '') end def otp_user_key @otp_user_key ||= if otp_keys_use_hmac? otp_hmac_secret(otp_key) else otp_key end end def otp_keys_use_hmac? !!hmac_secret end def possible_authentication_methods methods = super methods << 'totp' if otp_exists? && !@otp_tmp_key methods end private def _two_factor_auth_links links = super links << [20, otp_auth_path, otp_auth_link_text] if show_otp_auth_link? links end def _two_factor_setup_links links = super links << [20, otp_setup_path, otp_setup_link_text] unless otp_exists? links end def _two_factor_remove_links links = super links << [20, otp_disable_path, otp_disable_link_text] if otp_exists? links end def _two_factor_remove_all_from_session two_factor_remove_session('totp') super end def clear_cached_otp remove_instance_variable(:@otp) if defined?(@otp) end def otp_tmp_key(secret) _otp_tmp_key(secret) clear_cached_otp end def otp_hmac_secret(key) base32_encode(compute_raw_hmac(ROTP::Base32.decode(key)), key.bytesize) end def otp_hmac_old_secret(key) base32_encode(compute_raw_hmac_with_secret(ROTP::Base32.decode(key), hmac_old_secret), key.bytesize) end def otp_valid_key?(secret) return false unless secret =~ /\A([a-z2-7]{16}|[a-z2-7]{32})\z/ if otp_keys_use_hmac? # Purposely do not allow creating new OTPs with old secrets, # since OTP rotation is difficult. The user will get shown # the same page with an updated secret, which they can submit # to setup OTP. timing_safe_eql?(otp_hmac_secret(param(otp_setup_raw_param)), secret) else true end end if ROTP::Base32.respond_to?(:random_base32) def otp_new_secret ROTP::Base32.random_base32.downcase end else # :nocov: def otp_new_secret ROTP::Base32.random.downcase end # :nocov: end def base32_encode(data, length) chars = 'abcdefghijklmnopqrstuvwxyz234567' length.times.map{|i|chars[data[i].ord % 32]}.join end def _otp_tmp_key(secret) @otp_tmp_key = true @otp_user_key = nil @otp_key = secret end def _otp_interval otp_interval || 30 end # Called for valid OTP codes for old secrets def _otp_valid_code_for_old_secret end def _otp_add_key(secret) # Uniqueness errors can't be handled here, as we can't be sure the secret provided # is the same as the current secret. otp_key_ds.insert(otp_keys_id_column=>session_value, otp_keys_column=>secret) end def _otp_key @otp_user_key = nil otp_key_ds.get(otp_keys_column) end def _otp_for_key(key) otp_class.new(key, :issuer=>otp_issuer, :digits=>otp_digits, :interval=>otp_interval) end def _otp _otp_for_key(otp_user_key) end def otp_key_ds db[otp_keys_table].where(otp_keys_id_column=>session_value) end def show_otp_auth_link? otp_available? end def use_date_arithmetic? true end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/otp_lockout_email.rb000066400000000000000000000021601515725514200261330ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:otp_lockout_email, :OtpLockoutEmail) do depends :otp_unlock, :email_base loaded_templates %w'otp-locked-out-email otp-unlocked-email otp-unlock-failed-email' email :otp_locked_out, 'TOTP Authentication Locked Out', :translatable=>true email :otp_unlocked, 'TOTP Authentication Unlocked', :translatable=>true email :otp_unlock_failed, 'TOTP Authentication Unlocking Failed', :translatable=>true auth_value_method :send_otp_locked_out_email?, true auth_value_method :send_otp_unlocked_email?, true auth_value_method :send_otp_unlock_failed_email?, true private def after_otp_authentication_failure super if otp_locked_out? && send_otp_locked_out_email? send_otp_locked_out_email end end def after_otp_unlock_auth_success super if !otp_locked_out? && send_otp_unlocked_email? send_otp_unlocked_email end end def after_otp_unlock_auth_failure super if send_otp_unlock_failed_email? send_otp_unlock_failed_email end end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/otp_modify_email.rb000066400000000000000000000010011515725514200257330ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:otp_modify_email, :OtpModifyEmail) do depends :otp, :email_base loaded_templates %w'otp-setup-email otp-disabled-email' email :otp_setup, 'TOTP Authentication Setup', :translatable=>true email :otp_disabled, 'TOTP Authentication Disabled', :translatable=>true private def after_otp_setup super send_otp_setup_email end def after_otp_disable super send_otp_disabled_email end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/otp_unlock.rb000066400000000000000000000224421515725514200246040ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:otp_unlock, :OtpUnlock) do depends :otp before 'otp_unlock_attempt' after 'otp_unlock_auth_success' after 'otp_unlock_auth_failure' after 'otp_unlock_not_yet_available' error_flash "TOTP authentication is not currently locked out", 'otp_unlock_not_locked_out' error_flash "TOTP invalid authentication", 'otp_unlock_auth_failure' error_flash "Deadline past for unlocking TOTP authentication", 'otp_unlock_auth_deadline_passed' error_flash "TOTP unlock attempt not yet available", 'otp_unlock_auth_not_yet_available' notice_flash "TOTP authentication unlocked", 'otp_unlocked' notice_flash "TOTP successful authentication, more successful authentication needed to unlock", 'otp_unlock_auth_success' redirect :otp_unlock_not_locked_out redirect :otp_unlocked additional_form_tags button 'Authenticate Using TOTP to Unlock', 'otp_unlock' auth_value_method :otp_unlock_auth_deadline_passed_error_status, 403 auth_value_method :otp_unlock_auth_failure_cooldown_seconds, 900 auth_value_method :otp_unlock_auth_failure_error_status, 403 auth_value_method :otp_unlock_auth_not_yet_available_error_status, 403 auth_value_method :otp_unlock_auths_required, 3 auth_value_method :otp_unlock_deadline_seconds, 900 auth_value_method :otp_unlock_id_column, :id auth_value_method :otp_unlock_next_auth_attempt_after_column, :next_auth_attempt_after auth_value_method :otp_unlock_not_locked_out_error_status, 403 auth_value_method :otp_unlock_num_successes_column, :num_successes auth_value_method :otp_unlock_table, :account_otp_unlocks translatable_method :otp_unlock_consecutive_successes_label, 'Consecutive successful authentications' translatable_method :otp_unlock_form_footer, '' translatable_method :otp_unlock_next_auth_attempt_label, 'Can attempt next authentication after' translatable_method :otp_unlock_next_auth_attempt_refresh_label, 'Page will automatically refresh when authentication is possible.' translatable_method :otp_unlock_next_auth_deadline_label, 'Deadline for next authentication' translatable_method :otp_unlock_required_consecutive_successes_label, 'Required consecutive successful authentications to unlock' loaded_templates %w'otp-unlock otp-unlock-not-available' view 'otp-unlock', 'Unlock TOTP Authentication', 'otp_unlock' view 'otp-unlock-not-available', 'Must Wait to Unlock TOTP Authentication', 'otp_unlock_not_available' auth_methods( :otp_unlock_auth_failure, :otp_unlock_auth_success, :otp_unlock_available?, :otp_unlock_deadline_passed?, :otp_unlock_not_available_set_refresh_header, :otp_unlock_refresh_tag, ) route(:otp_unlock) do |r| require_login require_account_session require_otp_setup unless otp_locked_out? set_response_error_reason_status(:otp_not_locked_out, otp_unlock_not_locked_out_error_status) set_redirect_error_flash otp_unlock_not_locked_out_error_flash redirect otp_unlock_not_locked_out_redirect end before_otp_unlock_route r.get do if otp_unlock_available? otp_unlock_view else otp_unlock_not_available_set_refresh_header otp_unlock_not_available_view end end r.post do db.transaction do if otp_unlock_deadline_passed? set_response_error_reason_status(:otp_unlock_deadline_passed, otp_unlock_auth_deadline_passed_error_status) set_redirect_error_flash otp_unlock_auth_deadline_passed_error_flash elsif !otp_unlock_available? after_otp_unlock_not_yet_available set_response_error_reason_status(:otp_unlock_not_yet_available, otp_unlock_auth_not_yet_available_error_status) set_redirect_error_flash otp_unlock_auth_not_yet_available_error_flash else before_otp_unlock_attempt if otp_valid_code?(param(otp_auth_param)) otp_unlock_auth_success after_otp_unlock_auth_success unless otp_locked_out? set_notice_flash otp_unlocked_notice_flash redirect otp_unlocked_redirect end set_notice_flash otp_unlock_auth_success_notice_flash else otp_unlock_auth_failure after_otp_unlock_auth_failure set_response_error_reason_status(:otp_unlock_auth_failure, otp_unlock_auth_failure_error_status) set_redirect_error_flash otp_unlock_auth_failure_error_flash end end end redirect request.path end end def otp_unlock_available? if otp_unlock_data next_auth_attempt_after = otp_unlock_next_auth_attempt_after current_timestamp = Time.now if (next_auth_attempt_after < current_timestamp - otp_unlock_deadline_seconds) # Unlock process not fully completed within deadline, reset process otp_unlock_reset true else if next_auth_attempt_after > current_timestamp # If next auth attempt after timestamp is in the future, that means the next # unlock attempt cannot happen until then. false else if otp_unlock_num_successes == 0 # 0 value indicates previous attempt was a failure. Since failure cooldown # period has passed, reset process so user gets full deadline period otp_unlock_reset end true end end else # No row means no unlock attempts yet (or previous attempt was more than the # deadline account, so unlocking is available true end end def otp_unlock_auth_failure h = { otp_unlock_num_successes_column=>0, otp_unlock_next_auth_attempt_after_column=>Sequel.date_add(Sequel::CURRENT_TIMESTAMP, :seconds=>otp_unlock_auth_failure_cooldown_seconds) } if otp_unlock_ds.update(h) == 0 h[otp_unlock_id_column] = session_value # If row already exists when inserting, no need to do anything raises_uniqueness_violation?{otp_unlock_ds.insert(h)} end end def otp_unlock_auth_success deadline = Sequel.date_add(Sequel::CURRENT_TIMESTAMP, :seconds=>otp_unlock_success_cooldown_seconds) # Add WHERE to avoid possible race condition when multiple unlock auth requests # are sent at the same time (only the first should increment num successes). if otp_unlock_ds. where(Sequel[otp_unlock_next_auth_attempt_after_column] < Sequel::CURRENT_TIMESTAMP). update( otp_unlock_num_successes_column=>Sequel[otp_unlock_num_successes_column]+1, otp_unlock_next_auth_attempt_after_column=>deadline ) == 0 # Ignore uniqueness errors when inserting after a failed update, # which could be caused due to the race condition mentioned above. raises_uniqueness_violation? do otp_unlock_ds.insert( otp_unlock_id_column=>session_value, otp_unlock_next_auth_attempt_after_column=>deadline ) end end @otp_unlock_data = nil # :nocov: if otp_unlock_data # :nocov: if otp_unlock_num_successes >= otp_unlock_auths_required # At least the requisite number of consecutive successful unlock # authentications. Unlock OTP authentication. otp_key_ds.update(otp_keys_failures_column => 0) # Remove OTP unlock metadata when unlocking OTP authentication otp_unlock_reset # else # # Still need additional consecutive successful unlock attempts. end # else # # if row isn't available, probably the process was reset during this, # # and it's safe to do nothing in that case. end end def otp_unlock_deadline_passed? otp_unlock_data ? (otp_unlock_next_auth_attempt_after < Time.now - otp_unlock_deadline_seconds) : false end def otp_unlock_refresh_tag # RODAUTH3: Remove "" end def otp_lockout_redirect otp_unlock_path end def otp_unlock_next_auth_attempt_after if otp_unlock_data convert_timestamp(otp_unlock_data[otp_unlock_next_auth_attempt_after_column]) else Time.now end end def otp_unlock_deadline otp_unlock_next_auth_attempt_after + otp_unlock_deadline_seconds end def otp_unlock_num_successes otp_unlock_data ? otp_unlock_data[otp_unlock_num_successes_column] : 0 end def otp_unlock_not_available_set_refresh_header response.headers["refresh"] = ((otp_unlock_next_auth_attempt_after - Time.now).to_i + 1).to_s end private def show_otp_auth_link? super || (otp_exists? && otp_locked_out?) end def otp_unlock_data @otp_unlock_data ||= otp_unlock_ds.first end def otp_unlock_success_cooldown_seconds (_otp_interval+(otp_drift||0))*2 end def otp_unlock_reset otp_unlock_ds.delete @otp_unlock_data = nil end def otp_unlock_ds db[otp_unlock_table].where(otp_unlock_id_column=>session_value) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/password_complexity.rb000066400000000000000000000067411515725514200265520ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:password_complexity, :PasswordComplexity) do depends :login_password_requirements_base auth_value_method :password_dictionary_file, nil auth_value_method :password_dictionary, nil auth_value_method :password_character_groups, [/[a-z]/, /[A-Z]/, /\d/, /[^a-zA-Z\d]/] auth_value_method :password_min_groups, 3 auth_value_method :password_max_length_for_groups_check, 11 auth_value_method :password_max_repeating_characters, 3 auth_value_method :password_invalid_pattern, Regexp.union([/qwerty/i, /azerty/i, /asdf/i, /zxcv/i] + (1..8).map{|i| /#{i}#{i+1}#{(i+2)%10}/}) translatable_method :password_not_enough_character_groups_message, "does not include uppercase letters, lowercase letters, and numbers" translatable_method :password_invalid_pattern_message, "includes common character sequence" translatable_method :password_in_dictionary_message, "is a word in a dictionary" translatable_method :password_too_many_repeating_characters_message, "contains too many of the same character in a row" def password_meets_requirements?(password) super && \ password_has_enough_character_groups?(password) && \ password_has_no_invalid_pattern?(password) && \ password_not_too_many_repeating_characters?(password) && \ password_not_in_dictionary?(password) end def post_configure super return if method(:password_dictionary).owner != Rodauth::PasswordComplexity case password_dictionary_file when false # nothing when nil default_dictionary_file = '/usr/share/dict/words' # :nocov: if File.file?(default_dictionary_file) # :nocov: words = File.read(default_dictionary_file) end else words = File.read(password_dictionary_file) end return unless words require 'set' dict = Set.new(words.downcase.split) self.class.send(:define_method, :password_dictionary){dict} end private def password_has_enough_character_groups?(password) return true if password.length > password_max_length_for_groups_check return true if password_character_groups.select{|re| password =~ re}.length >= password_min_groups set_password_requirement_error_message(:not_enough_character_groups_in_password, password_not_enough_character_groups_message) false end def password_has_no_invalid_pattern?(password) return true unless password_invalid_pattern return true if password !~ password_invalid_pattern set_password_requirement_error_message(:invalid_password_pattern, password_invalid_pattern_message) false end def password_not_too_many_repeating_characters?(password) return true if password_max_repeating_characters < 2 return true if password !~ /(.)(\1){#{password_max_repeating_characters-1}}/ set_password_requirement_error_message(:too_many_repeating_characters_in_password, password_too_many_repeating_characters_message) false end def password_not_in_dictionary?(password) return true unless dict = password_dictionary return true unless password =~ /\A(?:\d*)([A-Za-z!@$+|][A-Za-z!@$+|0134578]+[A-Za-z!@$+|])(?:\d*)\z/ word = $1.downcase.tr('!@$+|0134578', 'iastloleastb') return true if !dict.include?(word) set_password_requirement_error_message(:password_in_dictionary, password_in_dictionary_message) false end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/password_expiration.rb000066400000000000000000000067141515725514200265370ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:password_expiration, :PasswordExpiration) do depends :login, :change_password error_flash "Your password has expired and needs to be changed" error_flash "Your password cannot be changed yet", 'password_not_changeable_yet' redirect :password_not_changeable_yet redirect(:password_change_needed){change_password_path} auth_value_method :allow_password_change_after, -86400 auth_value_method :require_password_change_after, 90*86400 auth_value_method :password_expiration_table, :account_password_change_times auth_value_method :password_expiration_id_column, :id auth_value_method :password_expiration_changed_at_column, :changed_at session_key :password_changed_at_session_key, :password_changed_at auth_value_method :password_expiration_default, false auth_methods( :password_expired?, :update_password_changed_at ) def get_password_changed_at convert_timestamp(password_expiration_ds.get(password_expiration_changed_at_column)) end def check_password_change_allowed if password_changed_at = get_password_changed_at if password_changed_at > Time.now - allow_password_change_after set_redirect_error_flash password_not_changeable_yet_error_flash redirect password_not_changeable_yet_redirect end end end def set_password(password) update_password_changed_at set_session_value(password_changed_at_session_key, Time.now.to_i) super end def account_from_reset_password_key(key) if a = super check_password_change_allowed end a end def update_password_changed_at ds = password_expiration_ds if ds.update(password_expiration_changed_at_column=>Sequel::CURRENT_TIMESTAMP) == 0 # Ignoring the violation is safe here, since a concurrent insert would also set it to the # current timestamp. ignore_uniqueness_violation{ds.insert(password_expiration_id_column=>account_id)} end end def require_current_password if authenticated? && password_expired? && password_change_needed_redirect != request.path_info set_redirect_error_flash password_expiration_error_flash redirect password_change_needed_redirect end end def password_expired? if password_changed_at = session[password_changed_at_session_key] return password_changed_at + require_password_change_after < Time.now.to_i end account_from_session if password_changed_at = get_password_changed_at set_session_value(password_changed_at_session_key, password_changed_at.to_i) password_changed_at + require_password_change_after < Time.now else set_session_value(password_changed_at_session_key, password_expiration_default ? 0 : 2147483647) password_expiration_default end end private def after_close_account super if defined?(super) password_expiration_ds.delete end def before_change_password_route check_password_change_allowed super end def after_create_account if account_password_hash_column update_password_changed_at end super if defined?(super) end def after_login require_current_password super end def password_expiration_ds db[password_expiration_table].where(password_expiration_id_column=>account_id) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/password_grace_period.rb000066400000000000000000000025301515725514200267700ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:password_grace_period, :PasswordGracePeriod) do auth_value_method :password_grace_period, 300 session_key :last_password_entry_session_key, :last_password_entry auth_methods :password_recently_entered? def modifications_require_password? return false unless super !password_recently_entered? end def password_match?(_) if v = super @last_password_entry = set_last_password_entry end v end def password_recently_entered? return false unless last_password_entry = session[last_password_entry_session_key] last_password_entry + password_grace_period > Time.now.to_i end def update_session super set_session_value(last_password_entry_session_key, @last_password_entry) if defined?(@last_password_entry) end private def after_create_account super if defined?(super) @last_password_entry = Time.now.to_i end def after_reset_password super if defined?(super) @last_password_entry = Time.now.to_i end def set_last_password_entry set_session_value(last_password_entry_session_key, Time.now.to_i) end def require_password_authentication? return true if defined?(super) && super !password_recently_entered? end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/password_pepper.rb000066400000000000000000000023001515725514200256330ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:password_pepper, :PasswordPepper) do depends :login_password_requirements_base auth_value_method :password_pepper, nil auth_value_method :previous_password_peppers, [""] auth_value_method :password_pepper_update?, true def password_match?(password) if (result = super) && @previous_pepper_matched && password_pepper_update? set_password(password) end result end def password_hash(password) super(password + password_pepper.to_s) end private def password_hash_match?(hash, password) return super if password_pepper.nil? return true if super(hash, password + password_pepper) @previous_pepper_matched = previous_password_peppers.any? do |pepper| super(hash, password + pepper) end end def database_function_password_match?(name, hash_id, password, salt) return super if password_pepper.nil? return true if super(name, hash_id, password + password_pepper, salt) @previous_pepper_matched = previous_password_peppers.any? do |pepper| super(name, hash_id, password + pepper, salt) end end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/path_class_methods.rb000066400000000000000000000012701515725514200262670ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:path_class_methods, :PathClassMethods) do def post_configure super klass = self.class klass.features.each do |feature_name| feature = FEATURES[feature_name] feature.routes.each do |handle_meth| route = handle_meth.to_s.sub(/\Ahandle_/, '') path_meth = :"#{route}_path" url_meth = :"#{route}_url" instance = klass.allocate.freeze klass.define_singleton_method(path_meth){|opts={}| instance.send(path_meth, opts)} klass.define_singleton_method(url_meth){|opts={}| instance.send(url_meth, opts)} end end end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/recovery_codes.rb000066400000000000000000000174071515725514200254470ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:recovery_codes, :RecoveryCodes) do depends :two_factor_base additional_form_tags 'recovery_auth' additional_form_tags 'recovery_codes' before 'add_recovery_codes' before 'view_recovery_codes' before 'recovery_auth' after 'add_recovery_codes' button 'Add Authentication Recovery Codes', 'add_recovery_codes' button 'Authenticate via Recovery Code', 'recovery_auth' button 'View Authentication Recovery Codes', 'view_recovery_codes' error_flash "Error authenticating via recovery code", 'invalid_recovery_code' error_flash "Unable to add recovery codes", 'add_recovery_codes' error_flash "Unable to view recovery codes", 'view_recovery_codes' notice_flash "Additional authentication recovery codes have been added", 'recovery_codes_added' redirect(:recovery_auth){recovery_auth_path} redirect(:add_recovery_codes){recovery_codes_path} loaded_templates %w'add-recovery-codes recovery-auth recovery-codes password-field' view 'add-recovery-codes', 'Authentication Recovery Codes', 'add_recovery_codes' view 'recovery-auth', 'Enter Authentication Recovery Code', 'recovery_auth' view 'recovery-codes', 'View Authentication Recovery Codes', 'recovery_codes' auth_value_method :add_recovery_codes_param, 'add' translatable_method :add_recovery_codes_heading, '

Add Additional Recovery Codes

' auth_value_method :auto_add_recovery_codes?, false auth_value_method :auto_remove_recovery_codes?, false translatable_method :invalid_recovery_code_message, "Invalid recovery code" auth_value_method :recovery_codes_limit, 16 auth_value_method :recovery_codes_column, :code auth_value_method :recovery_codes_id_column, :id translatable_method :recovery_codes_label, 'Recovery Code' auth_value_method :recovery_codes_param, 'recovery-code' auth_value_method :recovery_codes_table, :account_recovery_codes translatable_method :recovery_auth_link_text, "Authenticate Using Recovery Code" translatable_method :recovery_codes_link_text, "View Authentication Recovery Codes" auth_cached_method :recovery_codes auth_value_methods( :recovery_codes_primary? ) auth_methods( :add_recovery_code, :can_add_recovery_codes?, :new_recovery_code, :recovery_code_match?, :recovery_codes_available?, ) internal_request_method :recovery_codes internal_request_method :recovery_auth internal_request_method :valid_recovery_auth? route(:recovery_auth) do |r| require_login require_account_session require_two_factor_setup require_two_factor_not_authenticated('recovery_code') before_recovery_auth_route r.get do recovery_auth_view end r.post do if recovery_code_match?(param(recovery_codes_param)) before_recovery_auth two_factor_authenticate('recovery_code') end set_response_error_reason_status(:invalid_recovery_code, invalid_key_error_status) set_field_error(recovery_codes_param, invalid_recovery_code_message) set_error_flash invalid_recovery_code_error_flash recovery_auth_view end end route(:recovery_codes) do |r| require_account unless recovery_codes_primary? require_two_factor_setup require_two_factor_authenticated end before_recovery_codes_route r.get do recovery_codes_view end r.post do if two_factor_password_match?(param(password_param)) if can_add_recovery_codes? if param_or_nil(add_recovery_codes_param) transaction do before_add_recovery_codes add_recovery_codes(recovery_codes_limit - recovery_codes.length) after_add_recovery_codes end set_notice_now_flash recovery_codes_added_notice_flash end self.recovery_codes_button = add_recovery_codes_button end before_view_recovery_codes add_recovery_codes_view else if param_or_nil(add_recovery_codes_param) set_error_flash add_recovery_codes_error_flash else set_error_flash view_recovery_codes_error_flash end set_response_error_reason_status(:invalid_password, invalid_password_error_status) set_field_error(password_param, invalid_password_message) recovery_codes_view end end end attr_accessor :recovery_codes_button def two_factor_remove super recovery_codes_remove end def otp_add_key super if defined?(super) auto_add_missing_recovery_codes end def sms_confirm super if defined?(super) auto_add_missing_recovery_codes end def add_webauthn_credential(_) super if defined?(super) auto_add_missing_recovery_codes end def recovery_codes_remove recovery_codes_ds.delete end def recovery_code_match?(code) recovery_codes.each do |s| if timing_safe_eql?(code, s) recovery_codes_ds.where(recovery_codes_column=>code).delete if recovery_codes_primary? add_recovery_code end return true end end false end def can_add_recovery_codes? recovery_codes.length < recovery_codes_limit end def add_recovery_codes(number) return if number <= 0 transaction do number.times do add_recovery_code end end remove_instance_variable(:@recovery_codes) end def add_recovery_code # This should never raise uniqueness violations unless the recovery code is the same, and the odds of that # are 1/256**32 assuming a good random number generator. Still, attempt to handle that case by retrying # on such a uniqueness violation. retry_on_uniqueness_violation do recovery_codes_ds.insert(recovery_codes_id_column=>session_value, recovery_codes_column=>new_recovery_code) end end def recovery_codes_available? !recovery_codes_ds.empty? end def possible_authentication_methods methods = super methods << 'recovery_code' unless recovery_codes_ds.empty? methods end private def _two_factor_auth_links links = super links << [40, recovery_auth_path, recovery_auth_link_text] if recovery_codes_available? links end def _two_factor_setup_links links = super links << [40, recovery_codes_path, recovery_codes_link_text] if (recovery_codes_primary? || uses_two_factor_authentication?) links end def _two_factor_remove_all_from_session two_factor_remove_session('recovery_code') super end def after_otp_disable super if defined?(super) auto_remove_recovery_codes end def after_sms_disable super if defined?(super) auto_remove_recovery_codes end def after_webauthn_remove super if defined?(super) auto_remove_recovery_codes end def new_recovery_code random_key end def recovery_codes_primary? (features & [:otp, :sms_codes, :webauthn]).empty? end def auto_add_missing_recovery_codes if auto_add_recovery_codes? add_recovery_codes(recovery_codes_limit - recovery_codes.length) end end def auto_remove_recovery_codes if auto_remove_recovery_codes? && (%w'totp webauthn sms_code' & possible_authentication_methods).empty? recovery_codes_remove end end def _recovery_codes recovery_codes_ds.select_map(recovery_codes_column) end def recovery_codes_ds db[recovery_codes_table].where(recovery_codes_id_column=>session_value) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/remember.rb000066400000000000000000000202341515725514200242220ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:remember, :Remember) do notice_flash "Your remember setting has been updated" error_flash "There was an error updating your remember setting" loaded_templates %w'remember' view 'remember', 'Change Remember Setting' additional_form_tags button 'Change Remember Setting' before before 'load_memory' after after 'load_memory' redirect response auth_value_method :raw_remember_token_deadline, nil auth_value_method :remember_cookie_options, {}.freeze auth_value_method :extend_remember_deadline?, false auth_value_method :extend_remember_deadline_period, 3600 auth_value_method :remember_period, {:days=>14}.freeze auth_value_method :remember_deadline_interval, {:days=>14}.freeze auth_value_method :remember_id_column, :id auth_value_method :remember_key_column, :key auth_value_method :remember_deadline_column, :deadline auth_value_method :remember_table, :account_remember_keys auth_value_method :remember_cookie_key, '_remember' auth_value_method :remember_param, 'remember' auth_value_method :remember_remember_param_value, 'remember' auth_value_method :remember_forget_param_value, 'forget' auth_value_method :remember_disable_param_value, 'disable' session_key :remember_deadline_extended_session_key, :remember_deadline_extended_at translatable_method :remember_remember_label, 'Remember Me' translatable_method :remember_forget_label, 'Forget Me' translatable_method :remember_disable_label, 'Disable Remember Me' auth_methods( :add_remember_key, :disable_remember_login, :forget_login, :generate_remember_key_value, :get_remember_key, :load_memory, :remembered_session_id, :logged_in_via_remember_key?, :remember_key_value, :remember_login, :remove_remember_key ) internal_request_method :remember_setup internal_request_method :remember_disable internal_request_method :account_id_for_remember_key route do |r| require_account before_remember_route r.get do remember_view end r.post do remember = param(remember_param) if [remember_remember_param_value, remember_forget_param_value, remember_disable_param_value].include?(remember) transaction do before_remember # :nocov: case remember # :nocov: when remember_remember_param_value remember_login when remember_forget_param_value forget_login when remember_disable_param_value disable_remember_login end after_remember end remember_response else set_response_error_reason_status(:invalid_remember_param, invalid_field_error_status) set_error_flash remember_error_flash remember_view end end end def remembered_session_id return unless cookie = _get_remember_cookie id, key = cookie.split('_', 2) return unless id && key actual, deadline = active_remember_key_ds(id).get([remember_key_column, remember_deadline_column]) return unless actual if hmac_secret && !(valid = timing_safe_eql?(key, compute_hmac(actual))) if hmac_secret_rotation? && (valid = timing_safe_eql?(key, compute_old_hmac(actual))) _set_remember_cookie(id, actual, deadline) elsif !(raw_remember_token_deadline && raw_remember_token_deadline > convert_timestamp(deadline)) return end end unless valid || timing_safe_eql?(key, actual) return end id end def load_memory if logged_in? if extend_remember_deadline_while_logged_in? if account_from_session extend_remember_deadline else forget_login clear_session end end elsif account_from_remember_cookie before_load_memory login_session('remember') extend_remember_deadline if extend_remember_deadline? after_load_memory end end def remember_login get_remember_key set_remember_cookie set_session_value(remember_deadline_extended_session_key, Time.now.to_i) if extend_remember_deadline? end def forget_login opts = Hash[remember_cookie_options] opts[:path] = "/" unless opts.key?(:path) ::Rack::Utils.delete_cookie_header!(response.headers, remember_cookie_key, opts) end def get_remember_key unless @remember_key_value = active_remember_key_ds.get(remember_key_column) generate_remember_key_value transaction do remove_remember_key add_remember_key end end nil end def disable_remember_login remove_remember_key end def add_remember_key hash = {remember_id_column=>account_id, remember_key_column=>remember_key_value} set_deadline_value(hash, remember_deadline_column, remember_deadline_interval) if e = raised_uniqueness_violation{remember_key_ds.insert(hash)} # If inserting into the remember key table causes a violation, we can pull the # existing row from the table. If there is no invalid row, we can then reraise. raise e unless @remember_key_value = active_remember_key_ds.get(remember_key_column) end end def remove_remember_key(id=account_id) remember_key_ds(id).delete end def logged_in_via_remember_key? authenticated_by.include?('remember') end def clear_tokens(reason) super remove_remember_key remember_login if logged_in? && logged_in_via_remember_key? end private def _set_remember_cookie(account_id, remember_key_value, deadline) opts = Hash[remember_cookie_options] opts[:value] = "#{account_id}_#{convert_token_key(remember_key_value)}" opts[:expires] = convert_timestamp(deadline) opts[:path] = "/" unless opts.key?(:path) opts[:httponly] = true unless opts.key?(:httponly) || opts.key?(:http_only) opts[:secure] = true unless opts.key?(:secure) || !request.ssl? ::Rack::Utils.set_cookie_header!(response.headers, remember_cookie_key, opts) end def set_remember_cookie _set_remember_cookie(account_id, remember_key_value, active_remember_key_ds.get(remember_deadline_column)) end def extend_remember_deadline_while_logged_in? return false unless extend_remember_deadline? if extended_at = session[remember_deadline_extended_session_key] extended_at + extend_remember_deadline_period < Time.now.to_i elsif logged_in_via_remember_key? # Handle existing sessions before the change to extend remember deadline # while logged in. true end end def extend_remember_deadline active_remember_key_ds.update(remember_deadline_column=>Sequel.date_add(Sequel::CURRENT_TIMESTAMP, remember_period)) remember_login end def account_from_remember_cookie unless id = remembered_session_id # Only set expired cookie if there is already a cookie set. forget_login if _get_remember_cookie return end set_session_value(session_key, id) account_from_session remove_session_value(session_key) unless account remove_remember_key(id) forget_login return end account end def _get_remember_cookie request.cookies[remember_cookie_key] end def after_logout forget_login super if defined?(super) end def after_close_account remove_remember_key super if defined?(super) end attr_reader :remember_key_value def generate_remember_key_value @remember_key_value = random_key end def use_date_arithmetic? super || extend_remember_deadline? || db.database_type == :mysql end def remember_key_ds(id=account_id) db[remember_table].where(remember_id_column=>id) end def active_remember_key_ds(id=account_id) remember_key_ds(id).where(Sequel.expr(remember_deadline_column) > Sequel::CURRENT_TIMESTAMP) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/reset_password.rb000066400000000000000000000225111515725514200254700ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:reset_password, :ResetPassword) do depends :login, :email_base, :login_password_requirements_base notice_flash "Your password has been reset" notice_flash "An email has been sent to you with a link to reset the password for your account", 'reset_password_email_sent' error_flash "There was an error resetting your password" error_flash "There was an error requesting a password reset", 'reset_password_request' error_flash "An email has recently been sent to you with a link to reset your password", 'reset_password_email_recently_sent' error_flash "There was an error resetting your password: invalid or expired password reset key", 'no_matching_reset_password_key' loaded_templates %w'reset-password-request reset-password password-field password-confirm-field reset-password-email' view 'reset-password', 'Reset Password' view 'reset-password-request', 'Request Password Reset', 'reset_password_request' additional_form_tags additional_form_tags 'reset_password_request' before before 'reset_password_request' after after 'reset_password_request' button 'Reset Password' button 'Request Password Reset', 'reset_password_request' redirect redirect(:reset_password_email_sent){default_post_email_redirect} redirect(:reset_password_email_recently_sent){default_post_email_redirect} response response :reset_password_email_sent email :reset_password, 'Reset Password' auth_value_method :reset_password_deadline_column, :deadline auth_value_method :reset_password_deadline_interval, {:days=>1}.freeze auth_value_method :reset_password_key_param, 'key' auth_value_method :reset_password_autologin?, false auth_value_method :reset_password_table, :account_password_reset_keys auth_value_method :reset_password_id_column, :id auth_value_method :reset_password_key_column, :key auth_value_method :reset_password_email_last_sent_column, :email_last_sent translatable_method :reset_password_explanatory_text, "

If you have forgotten your password, you can request a password reset:

" auth_value_method :reset_password_skip_resend_email_within, 300 translatable_method :reset_password_request_link_text, "Forgot Password?" session_key :reset_password_session_key, :reset_password_key auth_methods( :create_reset_password_key, :get_reset_password_key, :get_reset_password_email_last_sent, :login_failed_reset_password_request_form, :remove_reset_password_key, :reset_password_email_link, :reset_password_key_insert_hash, :reset_password_key_value, :reset_password_request_for_unverified_account, :set_reset_password_email_last_sent ) auth_private_methods( :account_from_reset_password_key ) internal_request_method(:reset_password_request) internal_request_method route(:reset_password_request) do |r| check_already_logged_in before_reset_password_request_route r.get do reset_password_request_view end r.post do catch_error do unless account_from_login(login_param_value) throw_error_reason(:no_matching_login, no_matching_login_error_status, login_param, no_matching_login_message) end reset_password_request_for_unverified_account unless open_account? if reset_password_email_recently_sent? set_redirect_error_flash reset_password_email_recently_sent_error_flash redirect reset_password_email_recently_sent_redirect end generate_reset_password_key_value transaction do before_reset_password_request create_reset_password_key send_reset_password_email after_reset_password_request end reset_password_email_sent_response end set_error_flash reset_password_request_error_flash reset_password_request_view end end route do |r| check_already_logged_in before_reset_password_route @password_field_autocomplete_value = 'new-password' r.get do if key = param_or_nil(reset_password_key_param) set_session_value(reset_password_session_key, key) redirect(r.path) end if (key = session[reset_password_session_key]) && account_from_reset_password_key(key) reset_password_view else remove_session_value(reset_password_session_key) set_redirect_error_flash no_matching_reset_password_key_error_flash redirect require_login_redirect end end r.post do key = session[reset_password_session_key] || param(reset_password_key_param) unless account_from_reset_password_key(key) set_redirect_error_status(invalid_key_error_status) set_error_reason :invalid_reset_password_key set_redirect_error_flash reset_password_error_flash redirect reset_password_email_sent_redirect end password = param(password_param) catch_error do unless password_meets_requirements?(password) throw_error_status(invalid_field_error_status, password_param, password_does_not_meet_requirements_message) end if password_match?(password) throw_error_reason(:same_as_existing_password, invalid_field_error_status, password_param, same_as_existing_password_message) end if require_password_confirmation? && password != param(password_confirm_param) throw_error_reason(:passwords_do_not_match, unmatched_field_error_status, password_param, passwords_do_not_match_message) end transaction do before_reset_password set_password(password) clear_tokens(:reset_password) after_reset_password end if reset_password_autologin? autologin_session('reset_password') end remove_session_value(reset_password_session_key) reset_password_response end set_error_flash reset_password_error_flash reset_password_view end end def create_reset_password_key transaction do if reset_password_key_value = get_password_reset_key(account_id) set_reset_password_email_last_sent @reset_password_key_value = reset_password_key_value elsif e = raised_uniqueness_violation{password_reset_ds.insert(reset_password_key_insert_hash)} # If inserting into the reset password table causes a violation, we can pull the # existing reset password key from the table, or reraise. raise e unless @reset_password_key_value = get_password_reset_key(account_id) end end end def reset_password_request_for_unverified_account throw_error_reason(:unverified_account, unopen_account_error_status, login_param, unverified_account_message) end def remove_reset_password_key password_reset_ds.delete end def account_from_reset_password_key(key) @account = _account_from_reset_password_key(key) end def reset_password_email_link token_link(reset_password_route, reset_password_key_param, reset_password_key_value) end def get_password_reset_key(id) ds = password_reset_ds(id) ds.where(Sequel::CURRENT_TIMESTAMP > reset_password_deadline_column).delete ds.get(reset_password_key_column) end def set_reset_password_email_last_sent password_reset_ds.update(reset_password_email_last_sent_column=>Sequel::CURRENT_TIMESTAMP) if reset_password_email_last_sent_column end def get_reset_password_email_last_sent if column = reset_password_email_last_sent_column if ts = password_reset_ds.get(column) convert_timestamp(ts) end end end def reset_password_email_recently_sent? (email_last_sent = get_reset_password_email_last_sent) && (Time.now - email_last_sent < reset_password_skip_resend_email_within) end def clear_tokens(reason) super remove_reset_password_key end private def _login_form_footer_links super << [20, reset_password_request_path, reset_password_request_link_text] end attr_reader :reset_password_key_value def after_login_failure unless only_json? || internal_request? @login_form_header = login_failed_reset_password_request_form end super end def generate_reset_password_key_value @reset_password_key_value = random_key end def login_failed_reset_password_request_form render("reset-password-request") end def use_date_arithmetic? super || db.database_type == :mysql end def reset_password_key_insert_hash hash = {reset_password_id_column=>account_id, reset_password_key_column=>reset_password_key_value} set_deadline_value(hash, reset_password_deadline_column, reset_password_deadline_interval) hash end def password_reset_ds(id=account_id) db[reset_password_table].where(reset_password_id_column=>id) end def _account_from_reset_password_key(token) account_from_key(token, reset_password_account_status_value){|id| get_password_reset_key(id)} end def reset_password_account_status_value account_open_status_value end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/reset_password_notify.rb000066400000000000000000000006031515725514200270560ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:reset_password_notify, :ResetPasswordNotify) do depends :reset_password loaded_templates %w'reset-password-notify-email' email :reset_password_notify, 'Password Reset Completed', :translatable=>true private def after_reset_password super send_reset_password_notify_email end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/reset_password_verifies_account.rb000066400000000000000000000010141515725514200310730ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:reset_password_verifies_account, :ResetPasswordVerifiesAccount) do depends :reset_password, :verify_account def reset_password_request_for_unverified_account nil end private def after_reset_password super unless open_account? verify_account remove_verify_account_key end end def reset_password_account_status_value Array(super) << account_unverified_status_value end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/session_expiration.rb000066400000000000000000000031561515725514200263550ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:session_expiration, :SessionExpiration) do error_flash "This session has expired, please login again" redirect{require_login_redirect} auth_value_method :max_session_lifetime, 86400 session_key :session_created_session_key, :session_created_at auth_value_method :session_expiration_error_status, 401 auth_value_method :session_expiration_default, true auth_value_method :session_inactivity_timeout, 1800 session_key :session_last_activity_session_key, :last_session_activity_at def check_session_expiration return unless logged_in? unless session.has_key?(session_last_activity_session_key) && session.has_key?(session_created_session_key) if session_expiration_default expire_session end return end time = Time.now.to_i if session[session_last_activity_session_key] + session_inactivity_timeout < time expire_session end set_session_value(session_last_activity_session_key, time) if session[session_created_session_key] + max_session_lifetime < time expire_session end end def expire_session clear_session set_redirect_error_status session_expiration_error_status set_error_reason :session_expired set_redirect_error_flash session_expiration_error_flash redirect session_expiration_redirect end def update_session super t = Time.now.to_i set_session_value(session_last_activity_session_key, t) set_session_value(session_created_session_key, t) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/single_session.rb000066400000000000000000000064721515725514200254600ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:single_session, :SingleSession) do error_flash 'This session has been logged out as another session has become active' redirect auth_value_method :allow_raw_single_session_key?, false auth_value_method :inactive_session_error_status, 401 auth_value_method :single_session_id_column, :id auth_value_method :single_session_key_column, :key session_key :single_session_session_key, :single_session_key auth_value_method :single_session_table, :account_session_keys auth_methods( :currently_active_session?, :no_longer_active_session, :reset_single_session_key, :update_single_session_key ) def reset_single_session_key if logged_in? single_session_ds.update(single_session_key_column=>random_key) end end def currently_active_session? single_session_key = session[single_session_session_key] current_key = single_session_ds.get(single_session_key_column) if single_session_key.nil? unless current_key # No row exists for this user, indicating the feature has never # been used, so it is OK to treat the current session as a new # session. update_single_session_key end true elsif current_key if hmac_secret && !(valid = timing_safe_eql?(single_session_key, hmac = compute_hmac(current_key))) if hmac_secret_rotation? && (valid = timing_safe_eql?(single_session_key, compute_old_hmac(current_key))) session[single_session_session_key] = hmac elsif !allow_raw_single_session_key? return false end end valid || timing_safe_eql?(single_session_key, current_key) end end def check_single_session if logged_in? && !currently_active_session? no_longer_active_session end end def no_longer_active_session clear_session set_redirect_error_status inactive_session_error_status set_error_reason :inactive_session set_redirect_error_flash single_session_error_flash redirect single_session_redirect end def update_single_session_key key = random_key set_single_session_key(key) if single_session_ds.update(single_session_key_column=>key) == 0 # Don't handle uniqueness violations here. While we could get the stored key from the # database, it could lead to two sessions sharing the same key, which this feature is # designed to prevent. single_session_ds.insert(single_session_id_column=>session_value, single_session_key_column=>key) end end def update_session super update_single_session_key end def clear_tokens(reason) super single_session_ds(account_id).delete unless logged_in? end private def after_close_account super if defined?(super) single_session_ds.delete end def before_logout reset_single_session_key super if defined?(super) end def set_single_session_key(data) data = compute_hmac(data) if hmac_secret set_session_value(single_session_session_key, data) end def single_session_ds(id=session_value) db[single_session_table]. where(single_session_id_column=>id) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/sms_codes.rb000066400000000000000000000357701515725514200244160ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:sms_codes, :SmsCodes) do depends :two_factor_base additional_form_tags 'sms_auth' additional_form_tags 'sms_confirm' additional_form_tags 'sms_disable' additional_form_tags 'sms_request' additional_form_tags 'sms_setup' before 'sms_auth' before 'sms_confirm' before 'sms_disable' before 'sms_request' before 'sms_setup' after 'sms_confirm' after 'sms_disable' after 'sms_failure' after 'sms_request' after 'sms_setup' button 'Authenticate via SMS Code', 'sms_auth' button 'Confirm SMS Backup Number', 'sms_confirm' button 'Disable Backup SMS Authentication', 'sms_disable' button 'Send SMS Code', 'sms_request' button 'Setup SMS Backup Number', 'sms_setup' error_flash "Error authenticating via SMS code", 'sms_invalid_code' error_flash "Error disabling SMS authentication", 'sms_disable' error_flash "Error setting up SMS authentication", 'sms_setup' error_flash "Invalid or out of date SMS confirmation code used, must setup SMS authentication again", 'sms_invalid_confirmation_code' error_flash "No current SMS code for this account", 'no_current_sms_code' error_flash "SMS authentication has been locked out", 'sms_lockout' error_flash "SMS authentication has already been setup", 'sms_already_setup' error_flash "SMS authentication has not been setup yet", 'sms_not_setup' error_flash "SMS authentication needs confirmation", 'sms_needs_confirmation' notice_flash "SMS authentication code has been sent", 'sms_request' notice_flash "SMS authentication has been disabled", 'sms_disable' notice_flash "SMS authentication has been setup", 'sms_confirm' translatable_method :sms_auth_link_text, "Authenticate Using SMS Code" translatable_method :sms_setup_link_text, "Setup Backup SMS Authentication" translatable_method :sms_disable_link_text, "Disable SMS Authentication" redirect :sms_already_setup redirect :sms_confirm redirect :sms_disable redirect(:sms_auth){sms_auth_path} redirect(:sms_needs_confirmation){sms_confirm_path} redirect(:sms_needs_setup){sms_setup_path} redirect(:sms_request){sms_request_path} redirect(:sms_lockout){two_factor_auth_required_redirect} response :sms_confirm response :sms_disable response :sms_needs_confirmation loaded_templates %w'sms-auth sms-confirm sms-disable sms-request sms-setup sms-code-field password-field' view 'sms-auth', 'Authenticate via SMS Code', 'sms_auth' view 'sms-confirm', 'Confirm SMS Backup Number', 'sms_confirm' view 'sms-disable', 'Disable Backup SMS Authentication', 'sms_disable' view 'sms-request', 'Send SMS Code', 'sms_request' view 'sms-setup', 'Setup SMS Backup Number', 'sms_setup' auth_value_method :sms_already_setup_error_status, 403 auth_value_method :sms_needs_confirmation_error_status, 403 auth_value_method :sms_auth_code_length, 6 auth_value_method :sms_code_allowed_seconds, 300 auth_value_method :sms_code_column, :code translatable_method :sms_code_label, 'SMS Code' auth_value_method :sms_code_param, 'sms-code' auth_value_method :sms_codes_table, :account_sms_codes auth_value_method :sms_confirm_code_length, 12 auth_value_method :sms_confirm_deadline, 86400 auth_value_method :sms_failure_limit, 5 auth_value_method :sms_failures_column, :num_failures auth_value_method :sms_id_column, :id translatable_method :sms_invalid_code_message, "invalid SMS code" translatable_method :sms_invalid_phone_message, "invalid SMS phone number" auth_value_method :sms_issued_at_column, :code_issued_at auth_value_method :sms_phone_column, :phone_number translatable_method :sms_phone_label, 'Phone Number' auth_value_method :sms_phone_input_type, 'tel' auth_value_method :sms_phone_min_length, 7 auth_value_method :sms_phone_param, 'sms-phone' auth_cached_method :sms auth_value_methods( :sms_codes_primary?, :sms_needs_confirmation_notice_flash, :sms_request_response ) auth_methods( :sms_auth_message, :sms_available?, :sms_code_issued_at, :sms_code_match?, :sms_confirm_message, :sms_confirmation_match?, :sms_current_auth?, :sms_disable, :sms_failures, :sms_locked_out?, :sms_needs_confirmation?, :sms_new_auth_code, :sms_new_confirm_code, :sms_normalize_phone, :sms_record_failure, :sms_remove_expired_confirm_code, :sms_remove_failures, :sms_send, :sms_set_code, :sms_setup, :sms_setup?, :sms_valid_phone? ) internal_request_method :sms_setup internal_request_method :sms_confirm internal_request_method :sms_request internal_request_method :sms_auth internal_request_method :valid_sms_auth? internal_request_method :sms_disable route(:sms_request) do |r| require_login require_account_session require_two_factor_not_authenticated('sms_code') require_sms_available before_sms_request_route r.get do sms_request_view end r.post do transaction do before_sms_request sms_send_auth_code after_sms_request end require_response(:_sms_request_response) end end route(:sms_auth) do |r| require_login require_account_session require_two_factor_not_authenticated('sms_code') require_sms_available unless sms_current_auth? if sms_code sms_set_code(nil) end set_response_error_reason_status(:no_current_sms_code, invalid_key_error_status) set_redirect_error_flash no_current_sms_code_error_flash redirect sms_request_redirect end before_sms_auth_route r.get do sms_auth_view end r.post do transaction do if sms_code_match?(param(sms_code_param)) before_sms_auth sms_remove_failures two_factor_authenticate('sms_code') else sms_record_failure after_sms_failure end end set_response_error_reason_status(:invalid_sms_code, invalid_key_error_status) set_field_error(sms_code_param, sms_invalid_code_message) set_error_flash sms_invalid_code_error_flash sms_auth_view end end route(:sms_setup) do |r| require_account unless sms_codes_primary? require_two_factor_setup require_two_factor_authenticated end sms_remove_expired_confirm_code require_sms_not_setup if sms_needs_confirmation? set_redirect_error_status(sms_needs_confirmation_error_status) set_error_reason :sms_needs_confirmation set_redirect_error_flash sms_needs_confirmation_error_flash redirect sms_needs_confirmation_redirect end before_sms_setup_route r.get do sms_setup_view end r.post do catch_error do unless two_factor_password_match?(param(password_param)) throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message) end phone = sms_normalize_phone(param(sms_phone_param)) unless sms_valid_phone?(phone) throw_error_reason(:invalid_phone_number, invalid_field_error_status, sms_phone_param, sms_invalid_phone_message) end transaction do before_sms_setup sms_setup(phone) sms_send_confirm_code after_sms_setup end sms_needs_confirmation_response end set_error_flash sms_setup_error_flash sms_setup_view end end route(:sms_confirm) do |r| require_account unless sms_codes_primary? require_two_factor_setup require_two_factor_authenticated end sms_remove_expired_confirm_code require_sms_not_setup before_sms_confirm_route r.get do sms_confirm_view end r.post do if sms_confirmation_match?(param(sms_code_param)) transaction do before_sms_confirm sms_confirm after_sms_confirm unless two_factor_authenticated? two_factor_update_session('sms_code') end end sms_confirm_response end sms_confirm_failure set_redirect_error_status(invalid_key_error_status) set_error_reason :invalid_sms_confirmation_code set_redirect_error_flash sms_invalid_confirmation_code_error_flash redirect sms_needs_setup_redirect end end route(:sms_disable) do |r| require_account require_sms_setup before_sms_disable_route r.get do sms_disable_view end r.post do if two_factor_password_match?(param(password_param)) transaction do before_sms_disable sms_disable if two_factor_login_type_match?('sms_code') two_factor_remove_session('sms_code') end after_sms_disable end sms_disable_response end set_response_error_reason_status(:invalid_password, invalid_password_error_status) set_field_error(password_param, invalid_password_message) set_error_flash sms_disable_error_flash sms_disable_view end end def two_factor_remove super sms_disable end def two_factor_remove_auth_failures super sms_remove_failures end def require_sms_setup unless sms_setup? set_redirect_error_status(two_factor_not_setup_error_status) set_error_reason :sms_not_setup set_redirect_error_flash sms_not_setup_error_flash redirect sms_needs_setup_redirect end end def require_sms_not_setup if sms_setup? set_redirect_error_status(sms_already_setup_error_status) set_error_reason :sms_already_setup set_redirect_error_flash sms_already_setup_error_flash redirect sms_already_setup_redirect end end def require_sms_available require_sms_setup if sms_locked_out? set_redirect_error_status(lockout_error_status) set_error_reason :sms_locked_out set_redirect_error_flash sms_lockout_error_flash redirect sms_lockout_redirect end end def sms_code_match?(code) return false unless sms_current_auth? timing_safe_eql?(code, sms_code) end def sms_confirmation_match?(code) sms_needs_confirmation? && sms_code_match?(code) end def sms_disable sms_ds.delete @sms = nil end def sms_confirm_failure sms_ds.delete end def sms_setup(phone_number) # Cannot handle uniqueness violation here, as the phone number given may not match the # one in the table. sms_ds.insert(sms_id_column=>session_value, sms_phone_column=>phone_number, sms_failures_column => nil) remove_instance_variable(:@sms) if instance_variable_defined?(:@sms) end def sms_remove_failures return if sms_needs_confirmation? update_hash_ds(sms, sms_ds.exclude(sms_failures_column => nil), sms_failures_column => 0, sms_code_column => nil) end def sms_confirm update_hash_ds(sms, sms_ds.where(sms_failures_column => nil), sms_failures_column => 0, sms_code_column => nil) super if defined?(super) end def sms_send_auth_code code = sms_new_auth_code sms_set_code(code) sms_send(sms_phone, sms_auth_message(code)) end def sms_send_confirm_code code = sms_new_confirm_code sms_set_code(code) sms_send(sms_phone, sms_confirm_message(code)) end def sms_valid_phone?(phone) phone.length >= sms_phone_min_length end def sms_auth_message(code) "SMS authentication code for #{domain} is #{code}" end def sms_confirm_message(code) "SMS confirmation code for #{domain} is #{code}" end def sms_needs_confirmation_notice_flash sms_needs_confirmation_error_flash end def sms_set_code(code) update_sms(sms_code_column=>code, sms_issued_at_column=>Sequel::CURRENT_TIMESTAMP) end def sms_remove_expired_confirm_code db[sms_codes_table]. where(sms_id_column=>session_value, sms_failures_column => nil). where(Sequel[sms_issued_at_column] < Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, seconds: sms_confirm_deadline)). delete end def sms_record_failure update_sms(sms_failures_column=>Sequel.expr(sms_failures_column)+1) sms[sms_failures_column] = sms_ds.get(sms_failures_column) end def sms_phone sms[sms_phone_column] end def sms_code sms[sms_code_column] end def sms_code_issued_at convert_timestamp(sms[sms_issued_at_column]) end def sms_failures sms[sms_failures_column] end def sms_setup? return false unless sms !sms_needs_confirmation? end def sms_needs_confirmation? sms && sms_failures.nil? end def sms_available? sms_setup? && !sms_locked_out? end def sms_locked_out? sms_failures >= sms_failure_limit end def sms_current_auth? sms_code && sms_code_issued_at + sms_code_allowed_seconds > Time.now end def possible_authentication_methods methods = super methods << 'sms_code' if sms_setup? methods end private def _sms_request_response set_notice_flash sms_request_notice_flash redirect sms_auth_redirect end def _two_factor_auth_links links = super links << [30, sms_request_path, sms_auth_link_text] if sms_available? links end def _two_factor_setup_links links = super links << [30, sms_setup_path, sms_setup_link_text] if !sms_setup? && (sms_codes_primary? || uses_two_factor_authentication?) links end def _two_factor_remove_links links = super links << [30, sms_disable_path, sms_disable_link_text] if sms_setup? links end def _two_factor_remove_all_from_session two_factor_remove_session('sms_code') super end def sms_codes_primary? (features & [:otp, :webauthn]).empty? end def sms_normalize_phone(phone) phone.to_s.gsub(/\D+/, '') end def sms_new_auth_code SecureRandom.random_number(10**sms_auth_code_length).to_s.rjust(sms_auth_code_length, "0") end def sms_new_confirm_code SecureRandom.random_number(10**sms_confirm_code_length).to_s.rjust(sms_confirm_code_length, "0") end def sms_send(phone, message) raise ConfigurationError, "sms_send needs to be defined in the Rodauth configuration for SMS sending to work" end def update_sms(values) update_hash_ds(sms, sms_ds, values) end def _sms sms_ds.first end def sms_ds db[sms_codes_table].where(sms_id_column=>session_value) end def use_date_arithmetic? true end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/two_factor_base.rb000066400000000000000000000204601515725514200255660ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:two_factor_base, :TwoFactorBase) do loaded_templates %w'two-factor-manage two-factor-auth two-factor-disable' view 'two-factor-manage', 'Manage Multifactor Authentication', 'two_factor_manage' view 'two-factor-auth', 'Authenticate Using Additional Factor', 'two_factor_auth' view 'two-factor-disable', 'Remove All Multifactor Authentication Methods', 'two_factor_disable' before :two_factor_disable after :two_factor_authentication after :two_factor_disable additional_form_tags :two_factor_disable button "Remove All Multifactor Authentication Methods", :two_factor_disable redirect(:two_factor_auth) redirect(:two_factor_already_authenticated) redirect(:two_factor_disable) redirect(:two_factor_need_setup){two_factor_manage_path} redirect(:two_factor_auth_required){two_factor_auth_path} response :two_factor_disable notice_flash "You have been multifactor authenticated", "two_factor_auth" notice_flash "All multifactor authentication methods have been disabled", "two_factor_disable" error_flash "This account has not been setup for multifactor authentication", 'two_factor_not_setup' error_flash "You have already been multifactor authenticated", 'two_factor_already_authenticated' error_flash "You need to authenticate via an additional factor before continuing", 'two_factor_need_authentication' error_flash "Unable to remove all multifactor authentication methods", "two_factor_disable" auth_value_method :two_factor_already_authenticated_error_status, 403 auth_value_method :two_factor_need_authentication_error_status, 401 auth_value_method :two_factor_not_setup_error_status, 403 session_key :two_factor_setup_session_key, :two_factor_auth_setup session_key :two_factor_auth_redirect_session_key, :two_factor_auth_redirect translatable_method :two_factor_setup_heading, "

Setup Multifactor Authentication

" translatable_method :two_factor_remove_heading, "

Remove Multifactor Authentication

" translatable_method :two_factor_disable_link_text, "Remove All Multifactor Authentication Methods" auth_value_method :two_factor_auth_return_to_requested_location?, false auth_value_methods :two_factor_modifications_require_password? auth_methods( :two_factor_authenticated?, :two_factor_remove, :two_factor_remove_auth_failures, :two_factor_remove_session, :two_factor_update_session ) auth_private_methods( :two_factor_auth_links, :two_factor_auth_response, :two_factor_setup_links, :two_factor_remove_links ) internal_request_method :two_factor_disable route(:two_factor_manage, 'multifactor-manage') do |r| require_account before_two_factor_manage_route r.get do all_links = two_factor_setup_links + two_factor_remove_links if all_links.length == 1 redirect all_links[0][1] end two_factor_manage_view end end route(:two_factor_auth, 'multifactor-auth') do |r| require_login require_account_session require_two_factor_setup require_two_factor_not_authenticated before_two_factor_auth_route r.get do if two_factor_auth_links.length == 1 redirect two_factor_auth_links[0][1] end two_factor_auth_view end end route(:two_factor_disable, 'multifactor-disable') do |r| require_account require_two_factor_setup before_two_factor_disable_route r.get do two_factor_disable_view end r.post do if two_factor_password_match?(param(password_param)) transaction do before_two_factor_disable two_factor_remove _two_factor_remove_all_from_session after_two_factor_disable end two_factor_disable_response end set_response_error_reason_status(:invalid_password, invalid_password_error_status) set_field_error(password_param, invalid_password_message) set_error_flash two_factor_disable_error_flash two_factor_disable_view end end def two_factor_modifications_require_password? modifications_require_password? end def authenticated? super && !two_factor_partially_authenticated? end def require_authentication super require_two_factor_authenticated if two_factor_partially_authenticated? end def require_two_factor_setup # Avoid database query if already authenticated via 2nd factor return if two_factor_authenticated? return if uses_two_factor_authentication? set_redirect_error_status(two_factor_not_setup_error_status) set_error_reason :two_factor_not_setup set_redirect_error_flash two_factor_not_setup_error_flash redirect two_factor_need_setup_redirect end def require_two_factor_not_authenticated(auth_type = nil) if two_factor_authenticated? || (auth_type && two_factor_login_type_match?(auth_type)) set_redirect_error_status(two_factor_already_authenticated_error_status) set_error_reason :two_factor_already_authenticated set_redirect_error_flash two_factor_already_authenticated_error_flash redirect two_factor_already_authenticated_redirect end end def require_two_factor_authenticated unless two_factor_authenticated? if two_factor_auth_return_to_requested_location? set_session_value(two_factor_auth_redirect_session_key, request.fullpath) end set_redirect_error_status(two_factor_need_authentication_error_status) set_error_reason :two_factor_need_authentication set_redirect_error_flash two_factor_need_authentication_error_flash redirect two_factor_auth_required_redirect end end def two_factor_remove_auth_failures nil end def two_factor_password_match?(password) if two_factor_modifications_require_password? password_match?(password) else true end end def two_factor_partially_authenticated? logged_in? && !two_factor_authenticated? && uses_two_factor_authentication? end def two_factor_authenticated? authenticated_by && authenticated_by.length >= 2 end def two_factor_authentication_setup? possible_authentication_methods.length >= 2 end def uses_two_factor_authentication? return false unless logged_in? set_session_value(two_factor_setup_session_key, two_factor_authentication_setup?) unless session.has_key?(two_factor_setup_session_key) session[two_factor_setup_session_key] end def two_factor_login_type_match?(type) authenticated_by && authenticated_by.include?(type) end def two_factor_remove nil end def two_factor_auth_links @two_factor_auth_links ||= _filter_links(_two_factor_auth_links) end def two_factor_setup_links @two_factor_setup_links ||= _filter_links(_two_factor_setup_links) end def two_factor_remove_links @two_factor_remove_links ||= _filter_links(_two_factor_remove_links) end private def _two_factor_auth_links (super if defined?(super)) || [] end def _two_factor_setup_links [] end def _two_factor_remove_links [] end def _two_factor_remove_all_from_session nil end def after_close_account super if defined?(super) two_factor_remove end def two_factor_authenticate(type) two_factor_update_session(type) two_factor_remove_auth_failures after_two_factor_authentication require_response(:_two_factor_auth_response) end def _two_factor_auth_response saved_two_factor_auth_redirect = remove_session_value(two_factor_auth_redirect_session_key) set_notice_flash two_factor_auth_notice_flash redirect(saved_two_factor_auth_redirect || two_factor_auth_redirect) end def two_factor_remove_session(type) authenticated_by.delete(type) remove_session_value(two_factor_setup_session_key) if authenticated_by.empty? clear_session end end def two_factor_update_session(auth_type) authenticated_by << auth_type set_session_value(two_factor_setup_session_key, true) end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/update_password_hash.rb000066400000000000000000000011741515725514200266350ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:update_password_hash, :UpdatePasswordHash) do depends :login_password_requirements_base def password_match?(password) if (result = super) && update_password_hash? @update_password_hash = false set_password(password) end result end private def update_password_hash? password_hash_cost != @current_password_hash_cost || @update_password_hash end def get_password_hash if hash = super @current_password_hash_cost = extract_password_hash_cost(hash) end hash end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/verify_account.rb000066400000000000000000000245101515725514200254450ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:verify_account, :VerifyAccount) do depends :login, :create_account, :email_base error_flash "Unable to verify account" error_flash "Unable to resend verify account email", 'verify_account_resend' error_flash "An email has recently been sent to you with a link to verify your account", 'verify_account_email_recently_sent' error_flash "There was an error verifying your account: invalid verify account key", 'no_matching_verify_account_key' error_flash "The account you tried to create is currently awaiting verification", 'attempt_to_create_unverified_account' error_flash "The account you tried to login with is currently awaiting verification", 'attempt_to_login_to_unverified_account' notice_flash "Your account has been verified" notice_flash "An email has been sent to you with a link to verify your account", 'verify_account_email_sent' loaded_templates %w'verify-account verify-account-resend verify-account-email' view 'verify-account', 'Verify Account' view 'verify-account-resend', 'Resend Verification Email', 'resend_verify_account' additional_form_tags additional_form_tags 'verify_account_resend' after after 'verify_account_email_resend' before before 'verify_account_email_resend' button 'Verify Account' button 'Send Verification Email Again', 'verify_account_resend' redirect response response :verify_account_email_sent redirect(:verify_account_email_sent){default_post_email_redirect} redirect(:verify_account_email_recently_sent){default_post_email_redirect} email :verify_account, 'Verify Account' auth_value_method :verify_account_key_param, 'key' auth_value_method :verify_account_autologin?, true auth_value_method :verify_account_table, :account_verification_keys auth_value_method :verify_account_id_column, :id auth_value_method :verify_account_email_last_sent_column, :email_last_sent auth_value_method :verify_account_skip_resend_email_within, 300 auth_value_method :verify_account_key_column, :key translatable_method :verify_account_resend_explanatory_text, "

If you no longer have the email to verify the account, you can request that it be resent to you:

" translatable_method :verify_account_resend_link_text, "Resend Verify Account Information" session_key :verify_account_session_key, :verify_account_key auth_value_method :verify_account_set_password?, true auth_methods( :allow_resending_verify_account_email?, :create_verify_account_key, :get_verify_account_key, :get_verify_account_email_last_sent, :remove_verify_account_key, :set_verify_account_email_last_sent, :verify_account, :verify_account_email_link, :verify_account_key_insert_hash, :verify_account_key_value ) auth_private_methods( :account_from_verify_account_key ) internal_request_method(:verify_account_resend) internal_request_method route(:verify_account_resend) do |r| verify_account_check_already_logged_in before_verify_account_resend_route r.get do resend_verify_account_view end r.post do if account_from_login(login_param_value) && allow_resending_verify_account_email? if verify_account_email_recently_sent? set_redirect_error_flash verify_account_email_recently_sent_error_flash redirect verify_account_email_recently_sent_redirect end before_verify_account_email_resend if verify_account_email_resend after_verify_account_email_resend verify_account_email_sent_response end end set_redirect_error_status(no_matching_login_error_status) set_error_reason :no_matching_login set_redirect_error_flash verify_account_resend_error_flash redirect verify_account_email_sent_redirect end end route do |r| verify_account_check_already_logged_in before_verify_account_route @password_field_autocomplete_value = 'new-password' r.get do if key = param_or_nil(verify_account_key_param) set_session_value(verify_account_session_key, key) redirect(r.path) end if (key = session[verify_account_session_key]) && account_from_verify_account_key(key) verify_account_view else remove_session_value(verify_account_session_key) set_redirect_error_flash no_matching_verify_account_key_error_flash redirect require_login_redirect end end r.post do key = session[verify_account_session_key] || param(verify_account_key_param) unless account_from_verify_account_key(key) set_redirect_error_status(invalid_key_error_status) set_error_reason :invalid_verify_account_key set_redirect_error_flash verify_account_error_flash redirect verify_account_redirect end catch_error do if verify_account_set_password? password = param(password_param) if require_password_confirmation? && password != param(password_confirm_param) throw_error_reason(:passwords_do_not_match, unmatched_field_error_status, password_param, passwords_do_not_match_message) end unless password_meets_requirements?(password) throw_error_status(invalid_field_error_status, password_param, password_does_not_meet_requirements_message) end end transaction do before_verify_account verify_account if verify_account_set_password? set_password(password) end clear_tokens(:verify_account) after_verify_account end if verify_account_autologin? autologin_session('verify_account') end remove_session_value(verify_account_session_key) verify_account_response end set_error_flash verify_account_error_flash verify_account_view end end def require_login_confirmation? false end def allow_resending_verify_account_email? account[account_status_column] == account_unverified_status_value end def remove_verify_account_key verify_account_ds.delete end def verify_account update_account(account_status_column=>account_open_status_value) == 1 end def verify_account_email_resend if @verify_account_key_value = get_verify_account_key(account_id) set_verify_account_email_last_sent send_verify_account_email true end end def create_account_notice_flash verify_account_email_sent_notice_flash end def new_account(login) if account_from_login(login) && allow_resending_verify_account_email? set_response_error_reason_status(:already_an_unverified_account_with_this_login, unopen_account_error_status) set_error_flash attempt_to_create_unverified_account_error_flash return_response resend_verify_account_view end super end def account_from_verify_account_key(key) @account = _account_from_verify_account_key(key) end def account_initial_status_value account_unverified_status_value end def verify_account_email_link token_link(verify_account_route, verify_account_key_param, verify_account_key_value) end def get_verify_account_key(id) verify_account_ds(id).get(verify_account_key_column) end def skip_status_checks? false end def create_account_autologin? false end def create_account_set_password? return false if verify_account_set_password? super end def set_verify_account_email_last_sent verify_account_ds.update(verify_account_email_last_sent_column=>Sequel::CURRENT_TIMESTAMP) if verify_account_email_last_sent_column end def get_verify_account_email_last_sent if column = verify_account_email_last_sent_column if ts = verify_account_ds.get(column) convert_timestamp(ts) end end end def setup_account_verification generate_verify_account_key_value create_verify_account_key send_verify_account_email end def verify_account_email_recently_sent? account && (email_last_sent = get_verify_account_email_last_sent) && (Time.now - email_last_sent < verify_account_skip_resend_email_within) end def clear_tokens(reason) super remove_verify_account_key end private def _login_form_footer_links links = super if !param_or_nil(login_param) || ((account || account_from_login(login_param_value)) && allow_resending_verify_account_email?) links << [30, verify_account_resend_path, verify_account_resend_link_text] end links end attr_reader :verify_account_key_value def before_login_attempt unless open_account? set_response_error_reason_status(:unverified_account, unopen_account_error_status) set_error_flash attempt_to_login_to_unverified_account_error_flash return_response resend_verify_account_view end super end def after_create_account setup_account_verification super end def verify_account_check_already_logged_in check_already_logged_in end def generate_verify_account_key_value @verify_account_key_value = random_key end def create_verify_account_key ds = verify_account_ds transaction do if ds.empty? if e = raised_uniqueness_violation{ds.insert(verify_account_key_insert_hash)} # If inserting into the verify account table causes a violation, we can pull the # key from the verify account table, or reraise. raise e unless @verify_account_key_value = get_verify_account_key(account_id) end end end end def verify_account_key_insert_hash {verify_account_id_column=>account_id, verify_account_key_column=>verify_account_key_value} end def verify_account_ds(id=account_id) db[verify_account_table].where(verify_account_id_column=>id) end def _account_from_verify_account_key(token) account_from_key(token, account_unverified_status_value){|id| get_verify_account_key(id)} end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/verify_account_grace_period.rb000066400000000000000000000055031515725514200301510ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:verify_account_grace_period, :VerifyAccountGracePeriod) do depends :verify_account error_flash "Please verify this account before changing the login", "unverified_change_login" redirect :unverified_change_login auth_value_method :verification_requested_at_column, :requested_at session_key :unverified_account_session_key, :unverified_account auth_value_method :verify_account_grace_period, 86400 auth_methods( :account_in_unverified_grace_period? ) def verified_account? logged_in? && !session[unverified_account_session_key] end def create_account_autologin? true end def open_account? super || (account_in_unverified_grace_period? && has_password?) end def verify_account_set_password? false end def logged_in? super && !unverified_grace_period_expired? end def require_login if unverified_grace_period_expired? clear_session end super end def update_session super if account_in_unverified_grace_period? set_session_value(unverified_account_session_key, Time.now.to_i + verify_account_grace_period) end end private def after_close_account super if defined?(super) verify_account_ds.delete end def before_change_login_route unless verified_account? set_redirect_error_flash unverified_change_login_error_flash redirect unverified_change_login_redirect end super if defined?(super) end def allow_email_auth? (defined?(super) ? super : true) && !account_in_unverified_grace_period? end def verify_account_check_already_logged_in nil end def account_session_status_filter s = super if verify_account_grace_period grace_period_ds = db[verify_account_table]. select(verify_account_id_column). where((Sequel.date_add(verification_requested_at_column, :seconds=>verify_account_grace_period) > Sequel::CURRENT_TIMESTAMP)) s = Sequel.|(s, Sequel.expr(account_status_column=>account_unverified_status_value) & {account_id_column => grace_period_ds}) end s end def account_in_unverified_grace_period? return false unless account! account[account_status_column] == account_unverified_status_value && verify_account_grace_period && !verify_account_ds.where(Sequel.date_add(verification_requested_at_column, :seconds=>verify_account_grace_period) > Sequel::CURRENT_TIMESTAMP).empty? end def unverified_grace_period_expired? return false unless expires_at = session[unverified_account_session_key] expires_at.is_a?(Integer) && Time.now.to_i > expires_at end def use_date_arithmetic? true end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/verify_login_change.rb000066400000000000000000000167441515725514200264400ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:verify_login_change, :VerifyLoginChange) do depends :change_login, :email_base error_flash "Unable to verify login change" error_flash "Unable to change login as there is already an account with the new login", 'verify_login_change_duplicate_account' error_flash "There was an error verifying your login change: invalid verify login change key", 'no_matching_verify_login_change_key' notice_flash "Your login change has been verified" notice_flash "An email has been sent to you with a link to verify your login change", 'change_login_needs_verification' loaded_templates %w'verify-login-change verify-login-change-email' view 'verify-login-change', 'Verify Login Change' additional_form_tags after after 'verify_login_change_email' before before 'verify_login_change_email' button 'Verify Login Change' redirect response redirect(:verify_login_change_duplicate_account){require_login_redirect} auth_value_method :verify_login_change_autologin?, false auth_value_method :verify_login_change_deadline_column, :deadline auth_value_method :verify_login_change_deadline_interval, {:days=>1}.freeze translatable_method :verify_login_change_email_subject, 'Verify Login Change' auth_value_method :verify_login_change_id_column, :id auth_value_method :verify_login_change_key_column, :key auth_value_method :verify_login_change_key_param, 'key' auth_value_method :verify_login_change_login_column, :login session_key :verify_login_change_session_key, :verify_login_change_key auth_value_method :verify_login_change_table, :account_login_change_keys auth_methods( :create_verify_login_change_email, :create_verify_login_change_key, :get_verify_login_change_login_and_key, :remove_verify_login_change_key, :send_verify_login_change_email, :verify_login_change, :verify_login_change_email_body, :verify_login_change_email_link, :verify_login_change_key_insert_hash, :verify_login_change_key_value, :verify_login_change_new_login, :verify_login_change_old_login ) auth_private_methods( :account_from_verify_login_change_key ) internal_request_method route do |r| before_verify_login_change_route r.get do if key = param_or_nil(verify_login_change_key_param) set_session_value(verify_login_change_session_key, key) redirect(r.path) end if (key = session[verify_login_change_session_key]) && account_from_verify_login_change_key(key) verify_login_change_view else remove_session_value(verify_login_change_session_key) set_redirect_error_flash no_matching_verify_login_change_key_error_flash redirect require_login_redirect end end r.post do key = session[verify_login_change_session_key] || param(verify_login_change_key_param) unless account_from_verify_login_change_key(key) set_redirect_error_status(invalid_key_error_status) set_error_reason :invalid_verify_login_change_key set_redirect_error_flash verify_login_change_error_flash redirect verify_login_change_redirect end transaction do before_verify_login_change unless verify_login_change set_redirect_error_status(invalid_key_error_status) set_error_reason :already_an_account_with_this_login set_redirect_error_flash verify_login_change_duplicate_account_error_flash redirect verify_login_change_duplicate_account_redirect end remove_verify_login_change_key after_verify_login_change end if verify_login_change_autologin? autologin_session('verify_login_change') end remove_session_value(verify_login_change_session_key) verify_login_change_response end end def require_login_confirmation? false end def remove_verify_login_change_key verify_login_change_ds.delete end def verify_login_change unless res = _update_login(verify_login_change_new_login) remove_verify_login_change_key end res end def account_from_verify_login_change_key(key) @account = _account_from_verify_login_change_key(key) end def send_verify_login_change_email(login) send_email(create_verify_login_change_email(login)) end def verify_login_change_email_link token_link(verify_login_change_route, verify_login_change_key_param, verify_login_change_key_value) end def get_verify_login_change_login_and_key(id) verify_login_change_ds(id).get([verify_login_change_login_column, verify_login_change_key_column]) end def change_login_notice_flash change_login_needs_verification_notice_flash end def verify_login_change_old_login account_ds.get(login_column) end attr_reader :verify_login_change_key_value attr_reader :verify_login_change_new_login def clear_tokens(reason) super remove_verify_login_change_key end private def update_login(login) if _account_from_login(login) set_login_requirement_error_message(:already_an_account_with_this_login, already_an_account_with_this_login_message) return false end transaction do before_verify_login_change_email generate_verify_login_change_key_value @verify_login_change_new_login = login create_verify_login_change_key(login) send_verify_login_change_email(login) after_verify_login_change_email end true end def generate_verify_login_change_key_value @verify_login_change_key_value = random_key end def create_verify_login_change_key(login) ds = verify_login_change_ds transaction do ds.where((Sequel::CURRENT_TIMESTAMP > verify_login_change_deadline_column) | ~Sequel.expr(verify_login_change_login_column=>login)).delete if e = raised_uniqueness_violation{ds.insert(verify_login_change_key_insert_hash(login))} old_login, key = get_verify_login_change_login_and_key(account_id) # If inserting into the verify login change table causes a violation, we can pull the # key from the verify login change table if the logins match, or reraise. @verify_login_change_key_value = if old_login.downcase == login.downcase key end raise e unless @verify_login_change_key_value end end end def verify_login_change_key_insert_hash(login) hash = {verify_login_change_id_column=>account_id, verify_login_change_key_column=>verify_login_change_key_value, verify_login_change_login_column=>login} set_deadline_value(hash, verify_login_change_deadline_column, verify_login_change_deadline_interval) hash end def create_verify_login_change_email(login) create_email_to(login, verify_login_change_email_subject, verify_login_change_email_body) end def verify_login_change_email_body render('verify-login-change-email') end def verify_login_change_ds(id=account_id) db[verify_login_change_table].where(verify_login_change_id_column=>id) end def _account_from_verify_login_change_key(token) account_from_key(token) do |id| @verify_login_change_new_login, key = get_verify_login_change_login_and_key(id) key end end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/webauthn.rb000066400000000000000000000500521515725514200242420ustar00rootroot00000000000000# frozen-string-literal: true require 'webauthn' module Rodauth Feature.define(:webauthn, :Webauthn) do depends :two_factor_base loaded_templates %w'webauthn-setup webauthn-auth webauthn-remove' view 'webauthn-setup', 'Setup WebAuthn Authentication', 'webauthn_setup' view 'webauthn-auth', 'Authenticate Using WebAuthn', 'webauthn_auth' view 'webauthn-remove', 'Remove WebAuthn Authenticator', 'webauthn_remove' additional_form_tags 'webauthn_setup' additional_form_tags 'webauthn_auth' additional_form_tags 'webauthn_remove' before :webauthn_setup before :webauthn_auth before :webauthn_remove after :webauthn_setup after :webauthn_auth_failure after :webauthn_remove button 'Setup WebAuthn Authentication', 'webauthn_setup' button 'Authenticate Using WebAuthn', 'webauthn_auth' button 'Remove WebAuthn Authenticator', 'webauthn_remove' redirect :webauthn_setup redirect :webauthn_remove response :webauthn_setup response :webauthn_remove notice_flash "WebAuthn authentication is now setup", 'webauthn_setup' notice_flash "WebAuthn authenticator has been removed", 'webauthn_remove' error_flash "Error setting up WebAuthn authentication", 'webauthn_setup' error_flash "Error authenticating using WebAuthn", 'webauthn_auth' error_flash 'This account has not been setup for WebAuthn authentication', 'webauthn_not_setup' error_flash "Error removing WebAuthn authenticator", 'webauthn_remove' session_key :authenticated_webauthn_id_session_key, :webauthn_id translatable_method :webauthn_auth_link_text, "Authenticate Using WebAuthn" translatable_method :webauthn_setup_link_text, "Setup WebAuthn Authentication" translatable_method :webauthn_remove_link_text, "Remove WebAuthn Authenticator" auth_value_method :webauthn_setup_param, 'webauthn_setup' auth_value_method :webauthn_auth_param, 'webauthn_auth' auth_value_method :webauthn_remove_param, 'webauthn_remove' auth_value_method :webauthn_setup_challenge_param, 'webauthn_setup_challenge' auth_value_method :webauthn_setup_challenge_hmac_param, 'webauthn_setup_challenge_hmac' auth_value_method :webauthn_auth_challenge_param, 'webauthn_auth_challenge' auth_value_method :webauthn_auth_challenge_hmac_param, 'webauthn_auth_challenge_hmac' auth_value_method :webauthn_keys_account_id_column, :account_id auth_value_method :webauthn_keys_webauthn_id_column, :webauthn_id auth_value_method :webauthn_keys_public_key_column, :public_key auth_value_method :webauthn_keys_sign_count_column, :sign_count auth_value_method :webauthn_keys_last_use_column, :last_use auth_value_method :webauthn_keys_table, :account_webauthn_keys auth_value_method :webauthn_user_ids_account_id_column, :id auth_value_method :webauthn_user_ids_webauthn_id_column, :webauthn_id auth_value_method :webauthn_user_ids_table, :account_webauthn_user_ids auth_value_method :webauthn_setup_js, File.binread(File.expand_path('../../../../javascript/webauthn_setup.js', __FILE__)).freeze auth_value_method :webauthn_auth_js, File.binread(File.expand_path('../../../../javascript/webauthn_auth.js', __FILE__)).freeze auth_value_method :webauthn_js_host, '' auth_value_method :webauthn_setup_timeout, 120000 auth_value_method :webauthn_auth_timeout, 60000 auth_value_method :webauthn_user_verification, 'discouraged' auth_value_method :webauthn_attestation, 'none' auth_value_method :webauthn_not_setup_error_status, 403 translatable_method :webauthn_invalid_setup_param_message, "invalid webauthn setup param" translatable_method :webauthn_duplicate_webauthn_id_message, "attempt to insert duplicate webauthn id" translatable_method :webauthn_invalid_auth_param_message, "invalid webauthn authentication param" translatable_method :webauthn_invalid_sign_count_message, "webauthn credential has invalid sign count" translatable_method :webauthn_invalid_remove_param_message, "must select valid webauthn authenticator to remove" auth_value_methods( :webauthn_authenticator_selection, :webauthn_extensions, :webauthn_origin, :webauthn_rp_id, :webauthn_rp_name, ) auth_methods( :account_webauthn_ids, :account_webauthn_usage, :account_webauthn_user_id, :add_webauthn_credential, :authenticated_webauthn_id, :handle_webauthn_sign_count_verification_error, :new_webauthn_credential, :remove_webauthn_key, :remove_all_webauthn_keys_and_user_ids, :valid_new_webauthn_credential?, :valid_webauthn_credential_auth?, :webauthn_auth_js_path, :webauthn_credential_options_for_get, :webauthn_key_insert_hash, :webauthn_remove_authenticated_session, :webauthn_setup_js_path, :webauthn_update_session, :webauthn_user_name, ) def_deprecated_alias :webauthn_credential_options_for_get, :webauth_credential_options_for_get internal_request_method :webauthn_setup_params internal_request_method :webauthn_setup internal_request_method :webauthn_auth_params internal_request_method :webauthn_auth internal_request_method :webauthn_remove route(:webauthn_auth_js) do |r| before_webauthn_auth_js_route r.get do set_response_header('content-type', 'text/javascript') webauthn_auth_js end end route(:webauthn_auth) do |r| require_login require_account_session require_two_factor_not_authenticated('webauthn') require_webauthn_setup before_webauthn_auth_route r.get do webauthn_auth_view end r.post do catch_error do webauthn_credential = webauthn_auth_credential_from_form_submission transaction do before_webauthn_auth webauthn_update_session(webauthn_credential.id) two_factor_authenticate('webauthn') end end after_webauthn_auth_failure set_error_flash webauthn_auth_error_flash webauthn_auth_view end end route(:webauthn_setup_js) do |r| before_webauthn_setup_js_route r.get do set_response_header('content-type', 'text/javascript') webauthn_setup_js end end route(:webauthn_setup) do |r| require_authentication unless two_factor_login_type_match?('webauthn') require_account_session before_webauthn_setup_route r.get do webauthn_setup_view end r.post do catch_error do webauthn_credential = webauthn_setup_credential_from_form_submission throw_error = false transaction do before_webauthn_setup if raises_uniqueness_violation?{add_webauthn_credential(webauthn_credential)} throw_error = true raise Sequel::Rollback end unless two_factor_authenticated? webauthn_update_session(webauthn_credential.id) two_factor_update_session('webauthn') end after_webauthn_setup end if throw_error throw_error_reason(:duplicate_webauthn_id, invalid_field_error_status, webauthn_setup_param, webauthn_duplicate_webauthn_id_message) end webauthn_setup_response end set_error_flash webauthn_setup_error_flash webauthn_setup_view end end route(:webauthn_remove) do |r| require_authentication unless two_factor_login_type_match?('webauthn') require_account_session require_webauthn_setup before_webauthn_remove_route r.get do webauthn_remove_view end r.post do catch_error do unless webauthn_id = param_or_nil(webauthn_remove_param) throw_error_reason(:invalid_webauthn_remove_param, invalid_field_error_status, webauthn_remove_param, webauthn_invalid_remove_param_message) end unless two_factor_password_match?(param(password_param)) throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message) end transaction do before_webauthn_remove unless remove_webauthn_key(webauthn_id) throw_error_reason(:invalid_webauthn_remove_param, invalid_field_error_status, webauthn_remove_param, webauthn_invalid_remove_param_message) end if authenticated_webauthn_id == webauthn_id && two_factor_login_type_match?('webauthn') webauthn_remove_authenticated_session two_factor_remove_session('webauthn') end after_webauthn_remove end webauthn_remove_response end set_error_flash webauthn_remove_error_flash webauthn_remove_view end end def webauthn_auth_form_path webauthn_auth_path end def authenticated_webauthn_id session[authenticated_webauthn_id_session_key] end def webauthn_remove_authenticated_session remove_session_value(authenticated_webauthn_id_session_key) end def webauthn_update_session(webauthn_id) set_session_value(authenticated_webauthn_id_session_key, webauthn_id) end def webauthn_authenticator_selection {'requireResidentKey' => false, 'userVerification' => webauthn_user_verification} end def webauthn_extensions {} end def account_webauthn_ids webauthn_keys_ds.select_map(webauthn_keys_webauthn_id_column) end def account_webauthn_usage webauthn_keys_ds.select_hash(webauthn_keys_webauthn_id_column, webauthn_keys_last_use_column) end def account_webauthn_user_id unless webauthn_id = webauthn_user_ids_ds.get(webauthn_user_ids_webauthn_id_column) webauthn_id = WebAuthn.generate_user_id if e = raised_uniqueness_violation do webauthn_user_ids_ds.insert( webauthn_user_ids_account_id_column => webauthn_account_id, webauthn_user_ids_webauthn_id_column => webauthn_id ) end # If two requests to create a webauthn user id are sent at the same time and an insert # is attempted for both, one will fail with a unique constraint violation. In that case # it is safe for the second one to use the webauthn user id inserted by the other request. # If there is still no webauthn user id at this point, then we'll just reraise the # exception. # :nocov: raise e unless webauthn_id = webauthn_user_ids_ds.get(webauthn_user_ids_webauthn_id_column) # :nocov: end end webauthn_id end def new_webauthn_credential WebAuthn::Credential.options_for_create( :timeout => webauthn_setup_timeout, :user => {:id=>account_webauthn_user_id, :name=>webauthn_user_name}, :authenticator_selection => webauthn_authenticator_selection, :attestation => webauthn_attestation, :extensions => webauthn_extensions, :exclude => account_webauthn_ids, **webauthn_create_relying_party_opts ) end def valid_new_webauthn_credential?(webauthn_credential) _override_webauthn_credential_response_verify(webauthn_credential) (challenge = param_or_nil(webauthn_setup_challenge_param)) && (hmac = param_or_nil(webauthn_setup_challenge_hmac_param)) && (timing_safe_eql?(compute_hmac(challenge), hmac) || (hmac_secret_rotation? && timing_safe_eql?(compute_old_hmac(challenge), hmac))) && webauthn_credential.verify(challenge) end def webauthn_credential_options_for_get WebAuthn::Credential.options_for_get( :allow => webauthn_allow, :timeout => webauthn_auth_timeout, :user_verification => webauthn_user_verification, :extensions => webauthn_extensions, **webauthn_get_relying_party_opts ) end def webauthn_user_name account![login_column] end def webauthn_origin base_url end def webauthn_allow account_webauthn_ids end def webauthn_rp_id webauthn_origin.sub(/\Ahttps?:\/\//, '').sub(/:\d+\z/, '') end def webauthn_rp_name webauthn_rp_id end def handle_webauthn_sign_count_verification_error throw_error_reason(:invalid_webauthn_sign_count, invalid_field_error_status, webauthn_auth_param, webauthn_invalid_sign_count_message) end def add_webauthn_credential(webauthn_credential) webauthn_keys_ds.insert(webauthn_key_insert_hash(webauthn_credential)) super if defined?(super) nil end def valid_webauthn_credential_auth?(webauthn_credential) ds = webauthn_keys_ds.where(webauthn_keys_webauthn_id_column => webauthn_credential.id) pub_key, sign_count = ds.get([webauthn_keys_public_key_column, webauthn_keys_sign_count_column]) _override_webauthn_credential_response_verify(webauthn_credential) (challenge = param_or_nil(webauthn_auth_challenge_param)) && (hmac = param_or_nil(webauthn_auth_challenge_hmac_param)) && (timing_safe_eql?(compute_hmac(challenge), hmac) || (hmac_secret_rotation? && timing_safe_eql?(compute_old_hmac(challenge), hmac))) && webauthn_credential.verify(challenge, public_key: pub_key, sign_count: sign_count) && ds.update( webauthn_keys_sign_count_column => Integer(webauthn_credential.sign_count), webauthn_keys_last_use_column => Sequel::CURRENT_TIMESTAMP ) == 1 end def remove_webauthn_key(webauthn_id) webauthn_keys_ds.where(webauthn_keys_webauthn_id_column=>webauthn_id).delete == 1 end def remove_all_webauthn_keys_and_user_ids webauthn_user_ids_ds.delete webauthn_keys_ds.delete end def webauthn_setup? !webauthn_keys_ds.empty? end def require_webauthn_setup unless webauthn_setup? set_redirect_error_status(webauthn_not_setup_error_status) set_error_reason :webauthn_not_setup set_redirect_error_flash webauthn_not_setup_error_flash redirect two_factor_need_setup_redirect end end def two_factor_remove super remove_all_webauthn_keys_and_user_ids end def possible_authentication_methods methods = super methods << 'webauthn' if webauthn_setup? methods end private if WebAuthn::VERSION >= '3' if WebAuthn::RelyingParty.instance_method(:initialize).parameters.include?([:key, :allowed_origins]) def webauthn_relying_party # No need to memoize, only called once per request WebAuthn::RelyingParty.new( allowed_origins: [webauthn_origin], id: webauthn_rp_id, name: webauthn_rp_name, ) end # :nocov: else def webauthn_relying_party WebAuthn::RelyingParty.new( origin: webauthn_origin, id: webauthn_rp_id, name: webauthn_rp_name, ) end # :nocov: end def webauthn_create_relying_party_opts { :relying_party => webauthn_relying_party } end alias webauthn_get_relying_party_opts webauthn_create_relying_party_opts def webauthn_form_submission_call(meth, arg) WebAuthn::Credential.public_send(meth, arg, :relying_party => webauthn_relying_party) end def _override_webauthn_credential_response_verify(webauthn_credential) # no need to override end # :nocov: else def webauthn_create_relying_party_opts {:rp => {:name=>webauthn_rp_name, :id=>webauthn_rp_id}} end def webauthn_get_relying_party_opts { :rp_id => webauthn_rp_id } end def webauthn_form_submission_call(meth, arg) WebAuthn::Credential.public_send(meth, arg) end def _override_webauthn_credential_response_verify(webauthn_credential) # Hack around inability to override expected_origin and rp_id origin = webauthn_origin rp_id = webauthn_rp_id webauthn_credential.response.define_singleton_method(:verify) do |expected_challenge, expected_origin = nil, **kw| kw[:rp_id] = rp_id super(expected_challenge, expected_origin || origin, **kw) end end # :nocov: end def _two_factor_auth_links links = super links << [10, webauthn_auth_path, webauthn_auth_link_text] if webauthn_setup? && !two_factor_login_type_match?('webauthn') links end def _two_factor_setup_links super << [10, webauthn_setup_path, webauthn_setup_link_text] end def _two_factor_remove_links links = super links << [10, webauthn_remove_path, webauthn_remove_link_text] if webauthn_setup? links end def _two_factor_remove_all_from_session two_factor_remove_session('webauthn') remove_session_value(authenticated_webauthn_id_session_key) super end def webauthn_key_insert_hash(webauthn_credential) { webauthn_keys_account_id_column => webauthn_account_id, webauthn_keys_webauthn_id_column => webauthn_credential.id, webauthn_keys_public_key_column => webauthn_credential.public_key, webauthn_keys_sign_count_column => Integer(webauthn_credential.sign_count) } end def webauthn_account_id session_value end def webauthn_user_ids_ds db[webauthn_user_ids_table].where(webauthn_user_ids_account_id_column => webauthn_account_id) end def webauthn_keys_ds db[webauthn_keys_table].where(webauthn_keys_account_id_column => webauthn_account_id) end def webauthn_auth_credential_from_form_submission begin webauthn_credential = webauthn_form_submission_call(:from_get, webauthn_auth_data) unless valid_webauthn_credential_auth?(webauthn_credential) throw_error_reason(:invalid_webauthn_auth_param, invalid_key_error_status, webauthn_auth_param, webauthn_invalid_auth_param_message) end rescue WebAuthn::SignCountVerificationError handle_webauthn_sign_count_verification_error rescue WebAuthn::Error, RuntimeError, NoMethodError throw_error_reason(:invalid_webauthn_auth_param, invalid_field_error_status, webauthn_auth_param, webauthn_invalid_auth_param_message) end webauthn_credential end def webauthn_auth_data case auth_data = raw_param(webauthn_auth_param) when String begin JSON.parse(auth_data) rescue throw_error_reason(:invalid_webauthn_auth_param, invalid_field_error_status, webauthn_auth_param, webauthn_invalid_auth_param_message) end when Hash auth_data else throw_error_reason(:invalid_webauthn_auth_param, invalid_field_error_status, webauthn_auth_param, webauthn_invalid_auth_param_message) end end def webauthn_setup_credential_from_form_submission unless two_factor_password_match?(param(password_param)) throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message) end begin webauthn_credential = webauthn_form_submission_call(:from_create, webauthn_setup_data) unless valid_new_webauthn_credential?(webauthn_credential) throw_error_reason(:invalid_webauthn_setup_param, invalid_field_error_status, webauthn_setup_param, webauthn_invalid_setup_param_message) end rescue WebAuthn::Error, RuntimeError, NoMethodError throw_error_reason(:invalid_webauthn_setup_param, invalid_field_error_status, webauthn_setup_param, webauthn_invalid_setup_param_message) end webauthn_credential end def webauthn_setup_data case setup_data = raw_param(webauthn_setup_param) when String begin JSON.parse(setup_data) rescue throw_error_reason(:invalid_webauthn_setup_param, invalid_field_error_status, webauthn_setup_param, webauthn_invalid_setup_param_message) end when Hash setup_data else throw_error_reason(:invalid_webauthn_setup_param, invalid_field_error_status, webauthn_setup_param, webauthn_invalid_setup_param_message) end end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/webauthn_autofill.rb000066400000000000000000000034321515725514200261410ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:webauthn_autofill, :WebauthnAutofill) do depends :webauthn_login auth_value_method :webauthn_autofill?, true auth_value_method :webauthn_autofill_js, File.binread(File.expand_path('../../../../javascript/webauthn_autofill.js', __FILE__)).freeze translatable_method :webauthn_invalid_webauthn_id_message, "no webauthn key with given id found" route(:webauthn_autofill_js) do |r| before_webauthn_autofill_js_route r.get do set_response_header('content-type', 'text/javascript') webauthn_autofill_js end end def webauthn_allow return [] unless logged_in? || account super end def webauthn_user_verification 'preferred' end def webauthn_authenticator_selection super.merge({ 'residentKey' => 'required', 'requireResidentKey' => true }) end def login_field_autocomplete_value request.path_info == login_path ? "#{super} webauthn" : super end private def _login_form_footer footer = super footer += render("webauthn-autofill") if webauthn_autofill? && !valid_login_entered? footer end def account_from_webauthn_login return super if param_or_nil(login_param) credential_id = webauthn_auth_data["id"] account_id = db[webauthn_keys_table] .where(webauthn_keys_webauthn_id_column => credential_id) .get(webauthn_keys_account_id_column) unless account_id throw_error_reason(:invalid_webauthn_id, invalid_field_error_status, webauthn_auth_param, webauthn_invalid_webauthn_id_message) end account_from_id(account_id) end def webauthn_login_options? return true unless param_or_nil(login_param) super end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/webauthn_login.rb000066400000000000000000000046621515725514200254400ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:webauthn_login, :WebauthnLogin) do depends :login, :webauthn before redirect(:webauthn_login_failure){require_login_redirect} error_flash "There was an error authenticating via WebAuthn" auth_value_method :webauthn_login_user_verification_additional_factor?, false internal_request_method :webauthn_login_params internal_request_method :webauthn_login route(:webauthn_login) do |r| check_already_logged_in before_webauthn_login_route r.post do catch_error do unless account_from_webauthn_login && open_account? throw_error_reason(:no_matching_login, no_matching_login_error_status, login_param, no_matching_login_message) end webauthn_credential = webauthn_auth_credential_from_form_submission before_webauthn_login login('webauthn') do webauthn_update_session(webauthn_credential.id) if webauthn_login_verification_factor?(webauthn_credential) two_factor_update_session('webauthn-verification') end end end set_redirect_error_flash webauthn_login_error_flash redirect webauthn_login_failure_redirect end end def webauthn_auth_additional_form_tags if @webauthn_login super.to_s + login_hidden_field else super end end def webauthn_auth_form_path if @webauthn_login webauthn_login_path else super end end def webauthn_user_verification return 'preferred' if webauthn_login_user_verification_additional_factor? super end def use_multi_phase_login? true end private def webauthn_login_verification_factor?(webauthn_credential) webauthn_login_user_verification_additional_factor? && webauthn_credential.response.authenticator_data.user_verified? && uses_two_factor_authentication? end def account_from_webauthn_login account_from_login(login_param_value) end def webauthn_login_options? !!account_from_webauthn_login end def _multi_phase_login_forms forms = super if valid_login_entered? && webauthn_setup? @webauthn_login = true forms << [20, render('webauthn-auth'), nil] end forms end def webauthn_account_id super || account_id end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/webauthn_modify_email.rb000066400000000000000000000012151515725514200267550ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:webauthn_modify_email, :WebauthnModifyEmail) do depends :webauthn, :email_base loaded_templates %w'webauthn-authenticator-added-email webauthn-authenticator-removed-email' email :webauthn_authenticator_added, 'WebAuthn Authenticator Added', :translatable=>true email :webauthn_authenticator_removed, 'WebAuthn Authenticator Removed', :translatable=>true private def after_webauthn_setup super send_webauthn_authenticator_added_email end def after_webauthn_remove super send_webauthn_authenticator_removed_email end end end jeremyevans-rodauth-b53f402/lib/rodauth/features/webauthn_verify_account.rb000066400000000000000000000023471515725514200273460ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth Feature.define(:webauthn_verify_account, :WebauthnVerifyAccount) do depends :verify_account, :webauthn def verify_account_view webauthn_setup_view end def create_account_set_password? false end def verify_account_set_password? false end def autologin_session(autologin_type) super if autologin_type == 'verify_account' set_session_value(authenticated_by_session_key, ['webauthn']) remove_session_value(autologin_type_session_key) webauthn_update_session(@webauthn_credential.id) end end private def before_verify_account super if features.include?(:json) && use_json? && !param_or_nil(webauthn_setup_param) cred = new_webauthn_credential json_response[webauthn_setup_param] = cred.as_json json_response[webauthn_setup_challenge_param] = cred.challenge json_response[webauthn_setup_challenge_hmac_param] = compute_hmac(cred.challenge) end @webauthn_credential = webauthn_setup_credential_from_form_submission add_webauthn_credential(@webauthn_credential) end def webauthn_account_id super || account_id end end end jeremyevans-rodauth-b53f402/lib/rodauth/migrations.rb000066400000000000000000000107611515725514200227660ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth def self.create_database_authentication_functions(db, opts={}) table_name = opts[:table_name] || :account_password_hashes get_salt_name = opts[:get_salt_name] || :rodauth_get_salt valid_hash_name = opts[:valid_hash_name] || :rodauth_valid_password_hash argon2 = opts[:argon2] case db.database_type when :postgres search_path = opts[:search_path] || 'public, pg_temp' primary_key_type = case db.schema(table_name).find { |row| row.first == :id }[1][:db_type] when 'uuid' then :uuid else :int8 end table_name = db.literal(table_name) unless table_name.is_a?(String) argon_sql = <:account_previous_password_hashes, :get_salt_name=>:rodauth_get_previous_salt, :valid_hash_name=>:rodauth_previous_password_hash_match}.merge(opts)) end def self.drop_database_previous_password_check_functions(db, opts={}) drop_database_authentication_functions(db, {:table_name=>:account_previous_password_hashes, :get_salt_name=>:rodauth_get_previous_salt, :valid_hash_name=>:rodauth_previous_password_hash_match}.merge(opts)) end end jeremyevans-rodauth-b53f402/lib/rodauth/version.rb000066400000000000000000000011761515725514200222770ustar00rootroot00000000000000# frozen-string-literal: true module Rodauth # The major version of Rodauth, updated only for major changes that are # likely to require modification to apps using Rodauth. MAJOR = 2 # The minor version of Rodauth, updated for new feature releases of Rodauth. MINOR = 43 # The patch version of Rodauth, updated only for bug fixes from the last # feature release. TINY = 0 # The full version of Rodauth as a string VERSION = "#{MAJOR}.#{MINOR}.#{TINY}".freeze # The full version of Rodauth as a number (1.17.0 => 11700) VERSION_NUMBER = MAJOR*10000 + MINOR*100 + TINY def self.version VERSION end end jeremyevans-rodauth-b53f402/rodauth.gemspec000066400000000000000000000057241515725514200210670ustar00rootroot00000000000000require File.expand_path("../lib/rodauth/version", __FILE__) Gem::Specification.new do |s| s.name = 'rodauth' s.version = Rodauth.version s.platform = Gem::Platform::RUBY s.extra_rdoc_files = ["MIT-LICENSE"] s.rdoc_options += ["--quiet", "--line-numbers", "--inline-source", '--title', "Rodauth: Ruby's Most Advanced Authentication Framework", '--main', 'README.rdoc'] s.license = "MIT" s.summary = "Authentication and Account Management Framework for Rack Applications" s.author = "Jeremy Evans" s.email = "code@jeremyevans.net" s.homepage = "https://rodauth.jeremyevans.net" s.required_ruby_version = ">= 1.9.2" s.files = %w(MIT-LICENSE) + Dir["dict/*.txt"] + Dir["lib/**/*.rb"] + Dir["templates/*.str"] + Dir["javascript/*.js"] s.metadata = { 'bug_tracker_uri' => 'https://github.com/jeremyevans/rodauth/issues', 'changelog_uri' => 'https://rodauth.jeremyevans.net/rdoc/files/CHANGELOG.html', 'documentation_uri' => 'https://rodauth.jeremyevans.net/documentation.html', 'mailing_list_uri' => 'https://github.com/jeremyevans/rodauth/discussions', 'source_code_uri' => 'https://github.com/jeremyevans/rodauth', } s.description = <= 4"]) s.add_dependency('roda', [">= 2.6.0"]) s.add_development_dependency('tilt') s.add_development_dependency('rack_csrf') s.add_development_dependency('bcrypt') s.add_development_dependency('argon2', '>=2') s.add_development_dependency('mail') s.add_development_dependency('rotp') s.add_development_dependency('rqrcode') s.add_development_dependency('jwt') s.add_development_dependency('webauthn', '>=2') s.add_development_dependency("minitest", '>=5.0.0') s.add_development_dependency("minitest-global_expectations") s.add_development_dependency("minitest-hooks", '>=1.1.0') s.add_development_dependency("capybara", '>=2.1.0') end jeremyevans-rodauth-b53f402/spec/000077500000000000000000000000001515725514200167765ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/spec/account_expiration_spec.rb000066400000000000000000000174731515725514200242470ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth account expiration feature' do it "should force account expiration after x number of days since last login" do rodauth do enable :login, :logout, :account_expiration end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.last_account_login_at.strftime('%m%d%y')}" : "Not Logged"} end now = Time.now 2.times do login page.body.must_include "Logged In#{now.strftime('%m%d%y')}" logout end DB[:account_activity_times].update(:last_login_at => Time.now - 181*86400) 2.times do login page.body.must_include 'Not Logged' page.find('#error_flash').text.must_equal "You cannot log into this account as it has expired" end end [true, false].each do |before| it "should not allow resetting of passwords for expired accounts, when loading account_expiration #{before ? "before" : "after"}" do rodauth do features = [:reset_password, :account_expiration] features.reverse! if before enable :login, :logout, *features reset_password_email_last_sent_column nil end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.last_account_login_at.strftime('%m%d%y')}" : "Not Logged"} end now = Time.now login page.body.must_include "Logged In#{now.strftime('%m%d%y')}" logout visit '/login' click_link 'Forgot Password?' fill_in 'Login', :with=>'foo@example.com' click_button 'Request Password Reset' link = email_link(/(\/reset-password\?key=.+)$/) visit link fill_in 'Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Reset Password' page.find('#notice_flash').text.must_equal "Your password has been reset" page.current_path.must_equal '/' visit '/login' click_link 'Forgot Password?' fill_in 'Login', :with=>'foo@example.com' click_button 'Request Password Reset' link = email_link(/(\/reset-password\?key=.+)$/) DB[:account_activity_times].update(:last_login_at => Time.now - 181*86400) visit link page.title.must_equal 'Reset Password' fill_in 'Password', :with=>'01234567' fill_in 'Confirm Password', :with=>'01234567' click_button 'Reset Password' page.find('#error_flash').text.must_equal "You cannot log into this account as it has expired" page.body.must_include 'Not Logged' page.current_path.must_equal '/' visit '/login' click_link 'Forgot Password?' fill_in 'Login', :with=>'foo@example.com' click_button 'Request Password Reset' page.find('#error_flash').text.must_equal "You cannot log into this account as it has expired" page.body.must_include 'Not Logged' page.current_path.must_equal '/' end it "should not allow account unlocks for expired accounts, when loading account_expiration #{before ? "before" : "after"}" do rodauth do features = [:lockout, :account_expiration] features.reverse! if before enable :logout, *features max_invalid_logins 2 unlock_account_autologin? false end roda do |r| r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged")} end login logout visit '/login' fill_in 'Login', :with=>'foo@example.com' 3.times do fill_in 'Password', :with=>'012345678910' click_button 'Login' end page.body.must_include("This account is currently locked out") click_button 'Request Account Unlock' page.find('#notice_flash').text.must_equal 'An email has been sent to you with a link to unlock your account' link = email_link(/(\/unlock-account\?key=.+)$/) visit link click_button 'Unlock Account' page.find('#notice_flash').text.must_equal 'Your account has been unlocked' page.body.must_include('Not Logged') visit '/login' fill_in 'Login', :with=>'foo@example.com' 3.times do fill_in 'Password', :with=>'012345678910' click_button 'Login' end page.body.must_include("This account is currently locked out") click_button 'Request Account Unlock' page.find('#notice_flash').text.must_equal 'An email has been sent to you with a link to unlock your account' link = email_link(/(\/unlock-account\?key=.+)$/) DB[:account_activity_times].update(:last_login_at => Time.now - 181*86400) visit link click_button 'Unlock Account' page.find('#error_flash').text.must_equal "You cannot log into this account as it has expired" page.body.must_include 'Not Logged' page.current_path.must_equal '/' end end it "should not allow account unlock requests for expired accounts" do rodauth do enable :lockout, :account_expiration, :logout max_invalid_logins 2 unlock_account_autologin? false end roda do |r| r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged")} end login logout visit '/login' fill_in 'Login', :with=>'foo@example.com' 3.times do fill_in 'Password', :with=>'012345678910' click_button 'Login' end DB[:account_activity_times].update(:last_login_at => Time.now - 181*86400) page.body.must_include("This account is currently locked out") click_button 'Request Account Unlock' page.find('#error_flash').text.must_equal "You cannot log into this account as it has expired" page.body.must_include 'Not Logged' page.current_path.must_equal '/' end it "should use last activity time if configured" do rodauth do enable :login, :logout, :account_expiration expire_account_on_last_activity? true account_expiration_error_flash{"Account expired on #{account_expired_at.strftime('%m%d%y')}"} end roda do |r| r.is("a"){view :content=>"Logged In#{rodauth.last_account_activity_at.strftime('%m%d%y')}"} rodauth.update_last_activity r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.last_account_activity_at.strftime('%m%d%y')}" : 'Not Logged'} end now = Time.now login page.body.must_include "Logged In#{now.strftime('%m%d%y')}" DB[:account_activity_times].count.must_equal 1 DB[:account_activity_times].delete visit '/' DB[:account_activity_times].count.must_equal 1 t1 = now - 179*86400 DB[:account_activity_times].update(:last_activity_at => t1) visit '/a' page.body.must_include "Logged In#{t1.strftime('%m%d%y')}" logout t2 = now - 181*86400 DB[:account_activity_times].update(:last_activity_at => t2).must_equal 1 login page.body.must_include 'Not Logged' page.find('#error_flash').text.must_equal "Account expired on #{now.strftime('%m%d%y')}" DB[:account_activity_times].update(:expired_at=>t1).must_equal 1 login page.body.must_include 'Not Logged' page.find('#error_flash').text.must_equal "Account expired on #{t1.strftime('%m%d%y')}" end [true, false].each do |before| it "should remove account activity data when closing accounts, when loading account_expiration #{before ? "before" : "after"}" do rodauth do features = [:close_account, :account_expiration] features.reverse! if before enable :login, *features close_account_requires_password? false end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.last_account_login_at.strftime('%m%d%y')}" : "Not Logged"} end login DB[:account_activity_times].count.must_equal 1 visit '/close-account' click_button 'Close Account' DB[:account_activity_times].count.must_equal 0 end end end jeremyevans-rodauth-b53f402/spec/active_sessions_spec.rb000066400000000000000000000433511515725514200235440ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth active sessions feature' do [true, false].each do |before| it "should check that session is active, when loading active_sessions #{before ? "before" : "after"}" do rodauth do features = [:logout, :active_sessions] features.reverse! if before enable :login, *features hmac_secret '123' end roda do |r| r.is("precheck"){rodauth.currently_active_session? ? "Active" : "Inactive"} rodauth.check_active_session r.rodauth r.is("clear"){rodauth.clear_session; r.redirect '/'} r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end visit '/precheck' page.body.must_equal "Inactive" login page.body.must_include "Logged In" visit '/precheck' page.body.must_equal "Active" session1 = get_cookie('rack.session') logout visit '/' page.body.must_include "Not Logged" remove_cookie('rack.session') set_cookie('rack.session', session1) visit '/foo' page.current_path.must_equal '/' page.body.must_include "Not Logged" page.find('#error_flash').text.must_equal "This session has been logged out" login page.body.must_include "Logged In" session2 = get_cookie('rack.session') remove_cookie('rack.session') set_cookie('rack.session', session1) visit '/' page.body.must_include "Not Logged" page.find('#error_flash').text.must_equal "This session has been logged out" remove_cookie('rack.session') set_cookie('rack.session', session2) visit '/' page.body.must_include "Logged In" visit '/clear' page.current_path.must_equal '/' page.body.must_include "Not Logged" set_cookie('rack.session', session2) visit '/' page.body.must_include "Logged In" DB[:account_active_session_keys].delete visit '/precheck' page.body.must_equal "Inactive" visit '/' page.body.must_include "Not Logged" end end it "should support secret rotation via hmac_old_secret" do secret = '123' old_secret = nil rodauth do enable :login, :active_sessions hmac_secret{secret} hmac_old_secret{old_secret} active_sessions_redirect '/login' end roda do |r| r.rodauth rodauth.check_active_session r.root{view :content=>""} end login secret = '234' visit '/' page.current_path.must_equal '/login' DB[:account_active_session_keys].delete secret = '123' login DB[:account_active_session_keys].count.must_equal 1 key1 = DB[:account_active_session_keys].get(:session_id) secret = '234' old_secret = '123' visit '/' page.current_path.must_equal '/' DB[:account_active_session_keys].count.must_equal 1 key2 = DB[:account_active_session_keys].get(:session_id) key2.wont_equal key1 old_secret = nil visit '/' page.current_path.must_equal '/' DB[:account_active_session_keys].count.must_equal 1 DB[:account_active_session_keys].get(:session_id).must_equal key2 end it "should clear all active sessions except current when resetting password without a logged in session" do rodauth do enable :login, :reset_password, :active_sessions require_password_confirmation? false hmac_secret '123' end roda do |r| r.rodauth rodauth.check_active_session r.root{view :content=>rodauth.logged_in? ? "Logged In!" : "Not Logged"} end login visit '/login' login(:pass=>'01234567', :visit=>false) click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" remove_cookie('rack.session') visit email_link(/(\/reset-password\?key=.+)$/) fill_in 'Password', :with=>'012345678911' DB[:account_active_session_keys].count.must_equal 1 click_button "Reset Password" page.find('#notice_flash').text.must_equal "Your password has been reset" page.body.must_include "Not Logged" DB[:account_active_session_keys].count.must_equal 0 end it "should clear all active sessions when changing login in a logged in session" do rodauth do enable :login, :change_login, :active_sessions require_login_confirmation? false change_login_requires_password? false hmac_secret '123' end roda do |r| r.rodauth rodauth.check_active_session r.root{view :content=>rodauth.logged_in? ? "Logged In!" : "Not Logged"} end login session1 = get_cookie('rack.session') remove_cookie('rack.session') login visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' DB[:account_active_session_keys].count.must_equal 2 click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" DB[:account_active_session_keys].count.must_equal 1 visit '/' page.body.must_include "Logged In!" set_cookie('rack.session', session1) visit '/' page.body.must_include "Not Logged" end it "should remove previous active session when updating session" do rodauth do enable :create_account, :verify_account_grace_period, :active_sessions create_account_autologin? true verify_account_autologin? true hmac_secret '123' end roda do |r| r.rodauth r.root{view :content=>""} end visit "/create-account" fill_in "Login", with: "foo@example2.com" fill_in "Password", with: "secret" fill_in "Confirm Password", with: "secret" click_on "Create Account" DB[:account_active_session_keys].count.must_equal 1 visit email_link(/(\/verify-account\?key=.+)$/, "foo@example2.com") click_on "Verify Account" DB[:account_active_session_keys].count.must_equal 1 end it "should handle session inactivity and lifetime deadlines" do session_inactivity_deadline = 86400 session_lifetime_deadline = 86400*30 rodauth do enable :login, :logout, :active_sessions hmac_secret '123' session_inactivity_deadline{session_inactivity_deadline} session_lifetime_deadline{session_lifetime_deadline} end roda do |r| rodauth.check_active_session r.rodauth r.is("clear"){rodauth.clear_session; r.redirect '/'} r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login page.body.must_include "Logged In" past_time = lambda do |seconds| Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, :seconds=>seconds) end DB[:account_active_session_keys].update(:last_use=>past_time.call(86400/2)) visit '/' page.body.must_include "Logged In" DB[:account_active_session_keys].update(:last_use=>past_time.call(86400*2)) visit '/' page.body.must_include "Not Logged" login DB[:account_active_session_keys].update(:created_at=>past_time.call(86400*29)) visit '/' page.body.must_include "Logged In" DB[:account_active_session_keys].update(:created_at=>past_time.call(86400*31)) visit '/' page.body.must_include "Not Logged" session_inactivity_deadline = 50 login DB[:account_active_session_keys].update(:last_use=>past_time.call(25)) visit '/' page.body.must_include "Logged In" DB[:account_active_session_keys].update(:last_use=>past_time.call(75)) visit '/' page.body.must_include "Not Logged" session_lifetime_deadline = 100 login DB[:account_active_session_keys].update(:created_at=>past_time.call(50)) visit '/' page.body.must_include "Logged In" DB[:account_active_session_keys].update(:created_at=>past_time.call(150)) visit '/' page.body.must_include "Not Logged" session_inactivity_deadline = 50 session_lifetime_deadline = nil login DB[:account_active_session_keys].update(:last_use=>past_time.call(25)) visit '/' page.body.must_include "Logged In" DB[:account_active_session_keys].update(:last_use=>past_time.call(75)) visit '/' page.body.must_include "Not Logged" session_inactivity_deadline = nil session_lifetime_deadline = 100 login DB[:account_active_session_keys].update(:created_at=>past_time.call(50)) visit '/' page.body.must_include "Logged In" DB[:account_active_session_keys].update(:created_at=>past_time.call(150)) visit '/' page.body.must_include "Not Logged" session_inactivity_deadline = 10 session_lifetime_deadline = 100 login DB[:account_active_session_keys].update(:last_use=>past_time.call(5), :created_at=>past_time.call(50)) visit '/' page.body.must_include "Logged In" DB[:account_active_session_keys].update(:last_use=>past_time.call(15), :created_at=>past_time.call(150)) visit '/' page.body.must_include "Not Logged" session_inactivity_deadline = nil session_lifetime_deadline = nil login DB[:account_active_session_keys].update(:last_use=>past_time.call(5), :created_at=>past_time.call(50)) visit '/' page.body.must_include "Logged In" DB[:account_active_session_keys].update(:last_use=>past_time.call(86400), :created_at=>past_time.call(150)) visit '/' page.body.must_include "Logged In" t = DB[:account_active_session_keys].get(:last_use) t = Time.parse(t) if t.is_a?(String) t.must_be(:<, Time.now - 10) end it "should logout all sessions for account on logout if that option is selected" do rodauth do enable :login, :active_sessions hmac_secret '123' end roda do |r| rodauth.check_active_session r.rodauth r.is("clear"){rodauth.clear_session; r.redirect '/'} rodauth.session[rodauth.session_id_session_key] || '' end login session_id1 = page.body session1 = get_cookie('rack.session') visit '/clear' login session_id2 = page.body session2 = get_cookie('rack.session') session_id1.wont_equal session_id2 remove_cookie('rack.session') set_cookie('rack.session', session1) visit '/' page.body.must_equal session_id1 remove_cookie('rack.session') set_cookie('rack.session', session2) visit '/' page.body.must_equal session_id2 visit '/logout' check 'Logout all Logged In Sessions?' click_button 'Logout' remove_cookie('rack.session') set_cookie('rack.session', session1) visit '/' page.body.must_equal '' remove_cookie('rack.session') set_cookie('rack.session', session2) visit '/' page.body.must_equal '' end it "should support logging out given session" do rodauth do enable :login, :active_sessions hmac_secret '123' end roda do |r| r.is("clear"){rodauth.clear_session; ''} r.is("except_for", String){|i| rodauth.remove_all_active_sessions_except_for(i); ''} rodauth.check_active_session r.rodauth rodauth.session[rodauth.session_id_session_key] || '' end login session_id1 = page.body session1 = get_cookie('rack.session') visit '/clear' login session_id2 = page.body session2 = get_cookie('rack.session') session_id1.wont_equal session_id2 visit "/except_for/#{session_id2}" visit '/clear' remove_cookie('rack.session') set_cookie('rack.session', session1) visit '/' page.body.wont_equal session_id1 page.body.wont_equal session_id2 visit '/clear' remove_cookie('rack.session') set_cookie('rack.session', session2) visit '/' page.body.must_equal session_id2 end it "should support logging out current session" do rodauth do enable :login, :active_sessions hmac_secret '123' end roda do |r| r.is("clear"){rodauth.clear_session; ''} r.is("except_current"){rodauth.remove_all_active_sessions_except_current; ''} r.is("except_no_current"){rodauth.session.delete(rodauth.session_id_session_key); rodauth.remove_all_active_sessions_except_current; ''} rodauth.check_active_session r.rodauth rodauth.session[rodauth.session_id_session_key] || '' end login session_id1 = page.body session1 = get_cookie('rack.session') visit '/clear' login session_id2 = page.body session2 = get_cookie('rack.session') session_id1.wont_equal session_id2 visit "/except_current" visit '/clear' remove_cookie('rack.session') set_cookie('rack.session', session1) visit '/' page.body.wont_equal session_id1 page.body.wont_equal session_id2 visit '/clear' remove_cookie('rack.session') set_cookie('rack.session', session2) visit '/' page.body.must_equal session_id2 DB[:account_active_session_keys].count.must_equal 1 visit "/except_no_current" DB[:account_active_session_keys].count.must_equal 0 end it "should support logging out all sessions for not-logged in users" do rodauth do enable :login, :active_sessions, :reset_password hmac_secret '123' after_reset_password do remove_all_active_sessions end end roda do |r| rodauth.check_active_session r.rodauth r.is("clear"){rodauth.clear_session; r.redirect '/'} r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login page.body.must_include "Logged In" visit '/clear' page.body.must_include "Not Logged" DB[:account_active_session_keys].count.must_equal 1 visit '/reset-password-request' fill_in 'Login', :with=>'foo@example.com' click_button 'Request Password Reset' visit email_link(/(\/reset-password\?key=.+)$/) fill_in 'Password', :with=>'01234567' fill_in 'Confirm Password', :with=>'01234567' click_button 'Reset Password' DB[:account_active_session_keys].count.must_equal 0 end it "should handle duplicate session ids by sharing them by default" do random_key = nil rodauth do enable :login, :active_sessions hmac_secret '123' random_key{random_key ||= super()} end roda do |r| rodauth.check_active_session r.rodauth r.is("clear"){rodauth.clear_session; r.redirect '/'} rodauth.session[rodauth.session_id_session_key] || '' end login session_id1 = page.body session1 = get_cookie('rack.session') visit '/clear' login session_id2 = page.body session2 = get_cookie('rack.session') session_id1.must_equal session_id2 remove_cookie('rack.session') set_cookie('rack.session', session1) visit '/' page.body.must_equal session_id1 remove_cookie('rack.session') set_cookie('rack.session', session2) visit '/' page.body.must_equal session_id2 visit '/logout' click_button 'Logout' remove_cookie('rack.session') set_cookie('rack.session', session1) visit '/' page.body.must_equal '' remove_cookie('rack.session') set_cookie('rack.session', session2) visit '/' page.body.must_equal '' end [true, false].each do |before| it "should remove active session keys when closing accounts, when active_sessions is loaded #{before ? "before" : "after"}" do rodauth do features = [:close_account, :active_sessions] features.reverse! if before enable :login, *features close_account_requires_password? false hmac_secret '123' end roda do |r| rodauth.check_active_session r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login DB[:account_active_session_keys].count.must_equal 1 visit '/close-account' click_button 'Close Account' DB[:account_active_session_keys].count.must_equal 0 end end it "should handle cases where active session id is not set during logout, to handle cases where active_sessions was added after session creation" do rodauth do enable :login, :active_sessions hmac_secret '123' end roda do |r| r.rodauth rodauth.check_active_session r.get('remove_session_id'){session.delete(rodauth.session_id_session_key); r.redirect '/logout'} r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login visit '/remove_session_id' click_button 'Logout' page.title.must_equal 'Login' end it "should limit accounts to a single logged in session when using jwt" do rodauth do enable :login, :active_sessions hmac_secret '123' end roda(:jwt) do |r| rodauth.check_active_session r.rodauth r.post("clear"){rodauth.clear_session; [3]} rodauth.logged_in? ? [1] : [2] end json_login authorization1 = @authorization json_logout json_request.must_equal [200, [2]] @authorization = authorization1 json_request.must_equal [401, {'reason'=>'inactive_session', 'error'=>"This session has been logged out"}] json_login json_request.must_equal [200, [1]] authorization2 = @authorization @authorization = authorization1 json_request.must_equal [401, {'reason'=>'inactive_session', 'error'=>"This session has been logged out"}] @authorization = authorization2 json_request.must_equal [200, [1]] json_request('/clear').must_equal [200, [3]] json_login authorization3 = @authorization json_request.must_equal [200, [1]] @authorization = authorization2 json_request.must_equal [200, [1]] res = json_request("/logout", 'global_logout'=>'t') res.must_equal [200, {"success"=>'You have been logged out'}] @authorization = authorization2 json_request.must_equal [401, {'reason'=>'inactive_session', 'error'=>"This session has been logged out"}] json_request.must_equal [200, [2]] @authorization = authorization3 json_request.must_equal [401, {'reason'=>'inactive_session', 'error'=>"This session has been logged out"}] json_request.must_equal [200, [2]] end end jeremyevans-rodauth-b53f402/spec/all.rb000066400000000000000000000000541515725514200200720ustar00rootroot00000000000000Dir['./spec/*_spec.rb'].each{|f| require f} jeremyevans-rodauth-b53f402/spec/audit_logging_spec.rb000066400000000000000000000052531515725514200231560ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth audit_logging feature' do ds = DB[:account_authentication_audit_logs].order(Sequel.desc(:at), Sequel.desc(:id)) it "should handle audit logging of all actions" do rodauth do enable :login, :logout, :audit_logging end roda do |r| r.rodauth view :content=>"Logged In" end login account_id, at, message, metadata = ds.get([:account_id, :at, :message, :metadata]) account_id.must_equal DB[:accounts].get(:id) at = Time.parse(at) unless at.is_a?(Time) at.must_be(:>, Time.now - 86400) message.must_equal 'login' metadata.must_be_nil logout ds.where(message: 'logout').count.must_equal 1 login(:pass=>'012345678') ds.where(message: 'login_failure').count.must_equal 1 end it "should allow customizing of audit log messages and metadata" do rodauth do enable :login, :logout, :audit_logging audit_log_message_for :login, "Login Ahoy!" audit_log_message_for :login_failure do "Login failure for #{param(login_param)}" end audit_log_metadata_for :logout, {'details'=>'A wild logout appears!'} audit_log_metadata_for :login_failure do {'never_do_this'=>param(password_param)} end audit_log_message_default do |action| action.to_s.upcase end audit_log_metadata_default('nothing'=>'specific') end roda do |r| r.rodauth view :content=>"Logged In" end login account_id, at, message, metadata = ds.get([:account_id, :at, :message, :metadata]) account_id.must_equal DB[:accounts].get(:id) at = Time.parse(at) unless at.is_a?(Time) at.must_be(:>, Time.now - 86400) message.must_equal 'Login Ahoy!' metadata = JSON.parse(metadata) if metadata.is_a?(String) metadata.must_equal('nothing'=>'specific') logout message, metadata = ds.where(message: 'LOGOUT').get([:message, :metadata]) message.wont_equal nil metadata = JSON.parse(metadata) if metadata.is_a?(String) metadata.must_equal('details'=>'A wild logout appears!') login(:pass=>'012345678') message, metadata = ds.where(message: 'Login failure for foo@example.com').get([:message, :metadata]) message.wont_equal nil metadata = JSON.parse(metadata) if metadata.is_a?(String) metadata = JSON.parse(metadata) if metadata.is_a?(String) metadata.must_equal('never_do_this'=>'012345678') end it "should skip audit logging if there is no message" do rodauth do enable :login, :audit_logging audit_log_message_for :login, nil end roda do |r| r.rodauth view :content=>"Logged In" end login ds.must_be_empty end end jeremyevans-rodauth-b53f402/spec/change_login_spec.rb000066400000000000000000000242521515725514200227570ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth change_login feature' do it "should support changing logins for accounts" do DB[:accounts].insert(:email=>'foo2@example.com') require_password = false require_email = true rodauth do enable :login, :logout, :change_login change_login_requires_password?{require_password} require_email_address_logins?{require_email} end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-login' page.title.must_equal 'Change Login' fill_in 'Login', :with=>'foobar' fill_in 'Confirm Login', :with=>'foobar' click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.html.must_include("invalid login, not a valid email address") page.current_path.must_equal '/change-login' require_email = false fill_in 'Login', :with=>'fb' fill_in 'Confirm Login', :with=>'fb' click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.html.must_include("invalid login, minimum 3 characters") page.current_path.must_equal '/change-login' fill_in 'Login', :with=>'f'*256 fill_in 'Confirm Login', :with=>'f'*256 click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.html.must_include("invalid login, maximum 255 characters") page.current_path.must_equal '/change-login' fill_in 'Login', :with=>"\u1234"*200 fill_in 'Confirm Login', :with=>"\u1234"*200 click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.html.must_include("invalid login, maximum 255 bytes") page.current_path.must_equal '/change-login' fill_in 'Login', :with=>'foo@example.com' fill_in 'Confirm Login', :with=>'foo2@example.com' click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.html.must_include("logins do not match") page.current_path.must_equal '/change-login' fill_in 'Login', :with=>'foo2@example.com' click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.html.must_include("invalid login, already an account with this login") page.current_path.must_equal '/change-login' fill_in 'Login', :with=>'foo@example.com' fill_in 'Confirm Login', :with=>'foo@example.com' click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.html.must_include("invalid login, same as current login") page.current_path.must_equal '/change-login' fill_in 'Login', :with=>'foo3@example.com' fill_in 'Confirm Login', :with=>'foo3@example.com' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" page.current_path.must_equal '/' logout login(:login=>'foo3@example.com') page.current_path.must_equal '/' require_password = true visit '/change-login' fill_in 'Password', :with=>'012345678' fill_in 'Login', :with=>'foo4@example.com' fill_in 'Confirm Login', :with=>'foo4@example.com' click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.html.must_include("invalid password") page.current_path.must_equal '/change-login' fill_in 'Password', :with=>'0123456789' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" page.current_path.must_equal '/' logout login(:login=>'foo4@example.com') page.current_path.must_equal '/' end it "should support changing logins for accounts without login confirmation" do rodauth do enable :login, :change_login change_login_requires_password? false require_login_confirmation? false login_meets_requirements?{|login| login.length > 4} end roda do |r| r.rodauth r.root{view :content=>""} end login visit '/change-login' fill_in 'Login', :with=>'foo' click_button 'Change Login' page.html.must_include "invalid login" page.html.wont_include "invalid login," visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" end it "invalides reset password links after login change" do rodauth do enable :login, :change_login, :reset_password change_login_requires_password? false require_login_confirmation? false login_meets_requirements?{|login| login.length > 4} end roda do |r| r.rodauth r.root{view :content=>""} end login visit '/login' login(:pass=>'01234567', :visit=>false) click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" link = email_link(/(\/reset-password\?key=.+)$/) visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" visit link page.find('#error_flash').text.must_equal "There was an error resetting your password: invalid or expired password reset key" end [:jwt, :json].each do |json| it "should support changing logins via #{json}" do DB[:accounts].insert(:email=>'foo2@example.com') require_password = false rodauth do enable :login, :logout, :change_login change_login_requires_password?{require_password} end roda(json) do |r| r.rodauth end json_login res = json_request('/change-login', :login=>'foobar', "login-confirm"=>'foobar') res.must_equal [422, {'reason'=>"login_not_valid_email",'error'=>"There was an error changing your login", "field-error"=>["login", "invalid login, not a valid email address"]}] res = json_request('/change-login', :login=>'foo@example.com', "login-confirm"=>'foo2@example.com') res.must_equal [422, {'reason'=>"logins_do_not_match",'error'=>"There was an error changing your login", "field-error"=>["login", "logins do not match"]}] res = json_request('/change-login', :login=>'foo2@example.com', "login-confirm"=>'foo2@example.com') res.must_equal [422, {'reason'=>"already_an_account_with_this_login",'error'=>"There was an error changing your login", "field-error"=>["login", "invalid login, already an account with this login"]}] res = json_request('/change-login', :login=>'f', "login-confirm"=>'f') res.must_equal [422, {'reason'=>"login_too_short",'error'=>"There was an error changing your login", "field-error"=>["login", "invalid login, minimum 3 characters"]}] res = json_request('/change-login', :login=>'f'*256, "login-confirm"=>'f'*256) res.must_equal [422, {'reason'=>"login_too_long",'error'=>"There was an error changing your login", "field-error"=>["login", "invalid login, maximum 255 characters"]}] res = json_request('/change-login', :login=>'foo3@example.com', "login-confirm"=>'foo3@example.com') res.must_equal [200, {'success'=>"Your login has been changed"}] json_logout json_login(:login=>'foo3@example.com') require_password = true res = json_request('/change-login', :login=>'foo4@example.com', "login-confirm"=>'foo4@example.com', :password=>'012345678') res.must_equal [401, {'reason'=>"invalid_password",'error'=>"There was an error changing your login", "field-error"=>["password", "invalid password"]}] res = json_request('/change-login', :login=>'foo4@example.com', "login-confirm"=>'foo4@example.com', :password=>'0123456789') res.must_equal [200, {'success'=>"Your login has been changed"}] json_logout json_login(:login=>'foo4@example.com') end end it "should support changing logins using an internal request" do rodauth do enable :login, :change_login, :internal_request login_meets_requirements?{|login| login.length > 4} end roda do |r| r.rodauth r.root{rodauth.logged_in?.nil?.to_s} end proc do app.rodauth.change_login(:account_login=>'foo@example.com', :login=>'foo') end.must_raise Rodauth::InternalRequestError app.rodauth.change_login(:account_login=>'foo@example.com', :login=>'foo3@example.com').must_be_nil visit '/' page.body.must_equal 'true' login page.current_path.must_equal '/login' login(:login=>'foo3@example.com', :visit=>false) page.current_path.must_equal '/' page.body.must_equal 'false' end it "should support overriding the flash-then-redirect response" do DB[:accounts].insert(:email=>'foo2@example.com') rodauth do enable :login, :logout, :change_login change_login_requires_password? false require_email_address_logins? true change_login_response{ return_response("Change is gonna come") } end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-login' page.title.must_equal 'Change Login' fill_in 'Login', :with=>'foo3@example.com' fill_in 'Confirm Login', :with=>'foo3@example.com' click_button 'Change Login' page.body.must_equal "Change is gonna come" logout login(:login=>'foo3@example.com') page.current_path.must_equal '/' end it "should raise error if a *_response method does not return a response" do DB[:accounts].insert(:email=>'foo2@example.com') rodauth do enable :login, :logout, :change_login change_login_requires_password? false require_email_address_logins? true change_login_response{ "Change is gonna come" } end roda do |r| r.rodauth r.root{view :content=>""} end login visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' fill_in 'Confirm Login', :with=>'foo3@example.com' proc{click_button 'Change Login'}.must_raise Rodauth::ConfigurationError end end jeremyevans-rodauth-b53f402/spec/change_password_notify_spec.rb000066400000000000000000000015611515725514200250770ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth change_password_notify feature' do it "should email when using change password" do rodauth do enable :login, :logout, :change_password_notify change_password_requires_password? false end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-password' fill_in 'New Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" page.current_path.must_equal '/' email = email_sent email.subject.must_equal "Password Changed" email.body.to_s.must_equal <""} end login page.current_path.must_equal '/' visit '/change-password' page.title.must_equal 'Change Password' fill_in 'Password', :with=>'0123456789' fill_in 'New Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Change Password' page.html.must_include("passwords do not match") page.find('#error_flash').text.must_equal "There was an error changing your password" page.current_path.must_equal '/change-password' fill_in 'Password', :with=>'0123456' fill_in 'New Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Change Password' page.find('#error_flash').text.must_equal "There was an error changing your password" page.body.must_include 'invalid password' page.current_path.must_equal '/change-password' fill_in 'Password', :with=>'0123456789' fill_in 'New Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Change Password' page.find('#error_flash').text.must_equal "There was an error changing your password" page.body.must_include 'invalid password, same as current password' page.current_path.must_equal '/change-password' fill_in 'Password', :with=>'0123456789' fill_in 'New Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" page.current_path.must_equal '/' logout login page.html.must_include("invalid password") page.current_path.must_equal '/login' fill_in 'Password', :with=>'0123456' click_button 'Login' page.current_path.must_equal '/' require_password = false visit '/change-password' fill_in 'New Password', :with=>'012345678' fill_in 'Confirm Password', :with=>'012345678' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" page.current_path.must_equal '/' login(:pass=>'012345678') page.current_path.must_equal '/' end end it "should support changing passwords for accounts without confirmation" do rodauth do enable :login, :change_password modifications_require_password? false require_password_confirmation? false password_maximum_bytes 55 password_maximum_length 50 end roda do |r| r.rodauth r.root{view :content=>""} end login visit '/change-password' fill_in 'New Password', :with=>'0123' click_button 'Change Password' page.find('#error_flash').text.must_equal "There was an error changing your password" page.body.must_include 'invalid password, does not meet requirements (minimum 6 characters)' page.current_path.must_equal '/change-password' fill_in 'New Password', :with=>"f"*60 click_button 'Change Password' page.find('#error_flash').text.must_equal "There was an error changing your password" page.body.must_include 'invalid password, does not meet requirements (maximum 50 characters)' page.current_path.must_equal '/change-password' fill_in 'New Password', :with=>"\u1234"*40 click_button 'Change Password' page.find('#error_flash').text.must_equal "There was an error changing your password" page.body.must_include 'invalid password, does not meet requirements (maximum 55 bytes)' page.current_path.must_equal '/change-password' visit '/change-password' fill_in 'New Password', :with=>'012345678' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end it "should support invalid_previous_password_message" do rodauth do enable :login, :logout, :change_password invalid_previous_password_message "Previous password not correct" end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-password' page.title.must_equal 'Change Password' fill_in 'Password', :with=>'0123456' fill_in 'New Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Change Password' page.find('#error_flash').text.must_equal "There was an error changing your password" page.body.must_include 'Previous password not correct' page.current_path.must_equal '/change-password' end it "should support setting requirements for passwords" do rodauth do enable :login, :create_account, :change_password create_account_autologin? false password_meets_requirements? do |password| password =~ /banana/ end end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' fill_in 'Login', :with=>'foo2@example.com' fill_in 'Confirm Login', :with=>'foo2@example.com' fill_in 'Password', :with=>'apple' fill_in 'Confirm Password', :with=>'apple' click_button 'Create Account' page.html.must_include("invalid password, does not meet requirements") page.find('#error_flash').text.must_equal "There was an error creating your account" page.current_path.must_equal '/create-account' fill_in 'Password', :with=>'banana' fill_in 'Confirm Password', :with=>'banana' click_button 'Create Account' login(:login=>'foo2@example.com', :pass=>'banana') visit '/change-password' fill_in 'Password', :with=>'banana' fill_in 'New Password', :with=>'apple' fill_in 'Confirm Password', :with=>'apple' click_button 'Change Password' page.html.must_include("invalid password, does not meet requirements") page.find('#error_flash').text.must_equal "There was an error changing your password" page.current_path.must_equal '/change-password' fill_in 'Password', :with=>'banana' fill_in 'New Password', :with=>'my_banana_3' fill_in 'Confirm Password', :with=>'my_banana_3' click_button 'Change Password' page.current_path.must_equal '/' end [:jwt, :json].each do |json| it "should support changing passwords for accounts via #{json}" do require_password = true rodauth do enable :login, :logout, :change_password change_password_requires_password?{require_password} end roda(json) do |r| r.rodauth end json_login res = json_request('/change-password', :password=>'0123456789', "new-password"=>'0123456', "password-confirm"=>'0123456789') res.must_equal [422, {'reason'=>"passwords_do_not_match",'error'=>"There was an error changing your password", "field-error"=>["new-password", "passwords do not match"]}] res = json_request('/change-password', :password=>'0123456', "new-password"=>'0123456', "password-confirm"=>'0123456') res.must_equal [401, {'reason'=>"invalid_previous_password",'error'=>"There was an error changing your password", "field-error"=>["password", "invalid password"]}] res = json_request('/change-password', :password=>'0123456789', "new-password"=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [422, {'reason'=>"same_as_existing_password",'error'=>"There was an error changing your password", "field-error"=>["new-password", "invalid password, same as current password"]}] res = json_request('/change-password', :password=>'0123456789', "new-password"=>'0123456', "password-confirm"=>'0123456') res.must_equal [200, {'success'=>"Your password has been changed"}] json_logout res = json_login(:no_check=>true) res.must_equal [401, {'reason'=>"invalid_password",'error'=>"There was an error logging in", "field-error"=>["password", "invalid password"]}] json_login(:pass=>'0123456') require_password = false res = json_request('/change-password', "new-password"=>'012345678', "password-confirm"=>'012345678') res.must_equal [200, {'success'=>"Your password has been changed"}] json_logout json_login(:pass=>'012345678') end end it "should support changing passwords using an internal request" do rodauth do enable :login, :logout, :change_password, :internal_request end roda do |r| r.rodauth r.root{rodauth.logged_in?.nil?.to_s} end proc do app.rodauth.change_password(:account_login=>'foo@example.com', :new_password=>'foo') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.change_password(:account_login=>'foo@example.com', :password=>'foo') end.must_raise Rodauth::InternalRequestError app.rodauth.change_password(:account_login=>'foo@example.com', :new_password=>'0123456').must_be_nil visit '/' page.body.must_equal 'true' login page.current_path.must_equal '/login' login(:pass=>'0123456') page.current_path.must_equal '/' page.body.must_equal 'false' logout app.rodauth.change_password(:account_login=>'foo@example.com', :password=>'01234567').must_be_nil login(:pass=>'01234567') page.current_path.must_equal '/' page.body.must_equal 'false' end end jeremyevans-rodauth-b53f402/spec/close_account_spec.rb000066400000000000000000000117111515725514200231570ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth close_account feature' do it "should support closing accounts when passwords are not required" do rodauth do enable :login, :close_account close_account_requires_password? false end roda do |r| r.rodauth r.root{view(:content=>"")} end login page.current_path.must_equal '/' visit '/close-account' click_button 'Close Account' page.current_path.must_equal '/' DB[:accounts].select_map(:status_id).must_equal [3] end it "should update account information when closing accounts" do statuses = nil rodauth do enable :login, :close_account close_account_requires_password? false after_close_account{statuses = [account[:status_id], account_ds.get(:status_id)]} end roda do |r| r.rodauth r.root{view(:content=>"")} end login visit '/close-account' click_button 'Close Account' statuses[0].must_equal 3 statuses[1].must_equal 3 end it "should delete accounts when skip_status_checks? is true" do rodauth do enable :login, :close_account close_account_requires_password? false skip_status_checks? true end roda do |r| r.rodauth r.root{view(:content=>"")} end login page.current_path.must_equal '/' visit '/close-account' click_button 'Close Account' page.current_path.must_equal '/' DB[:accounts].count.must_equal 0 end it "should support closing accounts when passwords are required" do rodauth do enable :login, :close_account end roda do |r| r.rodauth r.root{view(:content=>"")} end login page.current_path.must_equal '/' visit '/close-account' fill_in 'Password', :with=>'012345678' click_button 'Close Account' page.find('#error_flash').text.must_equal "There was an error closing your account" page.html.must_include("invalid password") DB[:accounts].select_map(:status_id).must_equal [2] fill_in 'Password', :with=>'0123456789' click_button 'Close Account' page.find('#notice_flash').text.must_equal "Your account has been closed" page.current_path.must_equal '/' DB[:accounts].select_map(:status_id).must_equal [3] end it "should support closing accounts with overrides" do rodauth do enable :login, :close_account close_account do account_ds.update(:email => 'foo@bar.com', :status_id=>3) end close_account_route 'close' close_account_redirect '/login' end roda do |r| r.rodauth r.root{""} end login page.current_path.must_equal '/' visit '/close' page.title.must_equal 'Close Account' fill_in 'Password', :with=>'0123456789' click_button 'Close Account' page.find('#notice_flash').text.must_equal "Your account has been closed" page.current_path.must_equal '/login' DB[:accounts].select_map(:status_id).must_equal [3] DB[:accounts].select_map(:email).must_equal ['foo@bar.com'] end it "should close accounts when account_password_hash_column is set" do rodauth do enable :create_account, :close_account close_account_requires_password? false account_password_hash_column :ph end roda do |r| r.rodauth r.root{view(:content=>"")} end visit '/create-account' fill_in 'Login', :with=>'foo2@example.com' fill_in 'Confirm Login', :with=>'foo2@example.com' fill_in 'Password', :with=>'apple2' fill_in 'Confirm Password', :with=>'apple2' click_button 'Create Account' visit '/close-account' click_button 'Close Account' page.current_path.must_equal '/' DB[:accounts].where(:email=>'foo2@example.com').get(:status_id).must_equal 3 end [:jwt, :json].each do |json| it "should support closing accounts via #{json}" do rodauth do enable :login, :close_account end roda(json) do |r| r.rodauth end json_login res = json_request('/close-account', :password=>'0123456') res.must_equal [401, {'reason'=>"invalid_password",'error'=>"There was an error closing your account", "field-error"=>["password", "invalid password"]}] DB[:accounts].select_map(:status_id).must_equal [2] res = json_request('/close-account', :password=>'0123456789') res.must_equal [200, {'success'=>"Your account has been closed"}] DB[:accounts].select_map(:status_id).must_equal [3] end end it "should support closing accounts using an internal request" do rodauth do enable :login, :logout, :close_account, :internal_request end roda do |r| r.rodauth r.root{rodauth.logged_in?.nil?.to_s} end visit '/' page.body.must_equal 'true' login page.body.must_equal 'false' logout app.rodauth.close_account(:account_login=>'foo@example.com').must_be_nil login page.current_path.must_equal '/login' DB[:accounts].select_map(:status_id).must_equal [3] end end jeremyevans-rodauth-b53f402/spec/confirm_password_spec.rb000066400000000000000000000162521515725514200237220ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth confirm password feature' do [true, false].each do |before| it "should support confirming passwords, when loading confirm_password #{before ? "before" : "after"}" do rodauth do features = [:password_grace_period, :confirm_password] features.reverse! if before enable :login, :change_login, *features before_change_login_route do unless password_recently_entered? set_session_value(confirm_password_redirect_session_key, request.path_info) redirect '/confirm-password' end end end roda do |r| r.rodauth r.get("a"){rodauth.require_password_authentication; view(:content=>"authed")} r.get("from_remember"){rodauth.authenticated_by.replace ["remember"]; ""} r.get("reset") do session[rodauth.last_password_entry_session_key] = Time.now.to_i - 400 "a" end view :content=>"" end login visit '/change-login' page.title.must_equal 'Change Login' visit '/reset' page.body.must_equal 'a' visit "/a" page.title.must_equal 'Confirm Password' visit '/change-login' page.title.must_equal 'Confirm Password' fill_in 'Password', :with=>'012345678' click_button 'Confirm Password' page.find('#error_flash').text.must_equal "There was an error confirming your password" page.html.must_include("invalid password") fill_in 'Password', :with=>'0123456789' click_button 'Confirm Password' page.find('#notice_flash').text.must_equal "Your password has been confirmed" visit "/a" page.body.must_include "authed" visit "/from_remember" visit "/a" page.title.must_equal 'Confirm Password' fill_in 'Password', :with=>'0123456789' click_button 'Confirm Password' page.find('#notice_flash').text.must_equal "Your password has been confirmed" visit '/change-login' visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' fill_in 'Confirm Login', :with=>'foo3@example.com' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" end end it "should support confirming passwords for accounts using email auth" do rodauth do enable :login, :email_auth, :confirm_password email_auth_email_last_sent_column nil end roda do |r| r.rodauth r.root{view :content=>(rodauth.authenticated_by ? "Authenticated via #{rodauth.authenticated_by.join(' and ')}" : '')} end visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' click_button 'Send Login Link Via Email' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to login to your account" page.current_path.must_equal '/' link = email_link(/(\/email-auth\?key=.+)$/) visit link click_button 'Login' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' page.html.must_include "Authenticated via email_auth" visit '/confirm-password' fill_in 'Password', :with=>'0123456789' click_button 'Confirm Password' page.current_path.must_equal '/' page.html.must_include "Authenticated via password" end it "should allow requiring password confirmation" do rodauth do enable :login, :confirm_password, :password_grace_period login_return_to_requested_location? true end roda do |r| r.rodauth r.get("reset") do session[rodauth.last_password_entry_session_key] = Time.now.to_i - 400 "a" end r.get("page") do rodauth.require_password_authentication view :content=>"Password Authentication Passed: #{r.params['foo']}" end view :content=>"" end visit '/page?foo=bar' page.current_path.must_equal '/login' login(:visit=>false) page.body.must_include "Password Authentication Passed: bar" page.find('#notice_flash').text.must_equal "You have been logged in" visit '/reset' page.body.must_equal 'a' visit '/page?foo=bar' page.current_path.must_equal '/confirm-password' page.find('#error_flash').text.must_equal "You need to confirm your password before continuing" fill_in 'Password', :with=>'0123456789' click_button 'Confirm Password' page.find('#notice_flash').text.must_equal "Your password has been confirmed" page.body.must_include "Password Authentication Passed: bar" end it "should not display confirm password link on login page if route is disabled" do route = "confirm-password" rodauth do enable :login, :confirm_password, :email_auth, :recovery_codes confirm_password_route { route } auto_add_recovery_codes? true after_login { auto_add_missing_recovery_codes } end roda do |r| r.rodauth r.root{view :content=>"Home"} end visit '/login' fill_in 'Login', with: 'foo@example.com' click_button 'Login' click_button 'Send Login Link Via Email' link = email_link(/(\/email-auth\?key=.+)$/) visit link click_button 'Login' visit '/multifactor-auth' click_on 'Enter Password' page.current_path.must_equal '/confirm-password' route = nil visit '/multifactor-auth' page.current_path.must_equal '/recovery-auth' end [:jwt, :json].each do |json| it "should support confirming passwords via #{json}" do rodauth do enable :password_grace_period, :login, :change_password, :confirm_password end roda(json) do |r| r.rodauth response[CONTENT_TYPE_KEY] = 'application/json' r.post("reset"){rodauth.send(:set_session_value, rodauth.last_password_entry_session_key, Time.now.to_i - 400); [1]} r.post("page") do rodauth.require_password_authentication '1' end end json_login json_request('/reset').must_equal [200, [1]] res = json_request('/page') res.must_equal [401, {'reason'=>'password_authentication_required', 'error'=>"You need to confirm your password before continuing"}] res = json_request('/confirm-password', :password=>'0123456789') res.must_equal [200, {'success'=>"Your password has been confirmed"}] res = json_request('/page') res.must_equal [200, 1] res = json_request('/change-password', "new-password"=>'0123456', "password-confirm"=>'0123456') res.must_equal [200, {'success'=>"Your password has been changed"}] json_request('/reset').must_equal [200, [1]] res = json_request('/change-password', "new-password"=>'01234567', "password-confirm"=>'01234567') res.must_equal [401, {'reason'=>"invalid_previous_password","field-error"=>["password", "invalid password"], "error"=>"There was an error changing your password"}] res = json_request('/confirm-password', "password"=>'0123456') res.must_equal [200, {'success'=>"Your password has been confirmed"}] res = json_request('/change-password', "new-password"=>'01234567', "password-confirm"=>'01234567') res.must_equal [200, {'success'=>"Your password has been changed"}] end end end jeremyevans-rodauth-b53f402/spec/create_account_spec.rb000066400000000000000000000217511515725514200233220ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth create_account feature' do [false, true].each do |ph| it "should support creating accounts #{'with account_password_hash_column' if ph}" do rodauth do enable :login, :create_account account_password_hash_column :ph if ph create_account_autologin? false end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' page.find_by_id('password')[:autocomplete].must_equal 'new-password' fill_in 'Login', :with=>'foo@example.com' fill_in 'Confirm Login', :with=>'foo@example.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.html.must_include("invalid login, already an account with this login") page.find('#error_flash').text.must_equal "There was an error creating your account" page.current_path.must_equal '/create-account' fill_in 'Login', :with=>'foobar' fill_in 'Confirm Login', :with=>'foobar' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.html.must_include("invalid login, not a valid email address") page.find('#error_flash').text.must_equal "There was an error creating your account" page.current_path.must_equal '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.html.must_include("logins do not match") page.find('#error_flash').text.must_equal "There was an error creating your account" page.current_path.must_equal '/create-account' fill_in 'Confirm Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'012345678' click_button 'Create Account' page.html.must_include("passwords do not match") page.find('#error_flash').text.must_equal "There was an error creating your account" page.current_path.must_equal '/create-account' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.find('#notice_flash').text.must_equal "Your account has been created" page.current_path.must_equal '/' login(:login=>'foo@example2.com') page.current_path.must_equal '/' end end it "should support creating accounts without login/password confirmation" do rodauth do enable :login, :create_account require_login_confirmation? false require_password_confirmation? false end roda do |r| r.rodauth r.root{view :content=>"Autologin-#{rodauth.autologin_type}"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' click_button 'Create Account' page.find('#notice_flash').text.must_equal "Your account has been created" page.html.must_include 'Autologin-create_account' end it "should support autologin after account creation" do rodauth do enable :create_account end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In: #{DB[:accounts].where(:id=>rodauth.session_value).get(:email)}"} end visit '/create-account' fill_in 'Login', :with=>'foo2@example.com' fill_in 'Confirm Login', :with=>'foo2@example.com' fill_in 'Password', :with=>'apple2' fill_in 'Confirm Password', :with=>'apple2' click_button 'Create Account' page.html.must_include("Logged In: foo2@example.com") end it "should do a case insensitive confirmation by default" do rodauth do enable :create_account end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In: #{DB[:accounts].where(:id=>rodauth.session_value).get(:email)}"} end visit '/create-account' fill_in 'Login', :with=>'foo2@example.com' fill_in 'Confirm Login', :with=>'FOO2@example.com' fill_in 'Password', :with=>'apple2' fill_in 'Confirm Password', :with=>'apple2' click_button 'Create Account' page.html.must_include("Logged In: foo2@example.com") end it "should support login_confirmation_matches? to allow for case sensitive confirmations" do rodauth do enable :create_account login_confirmation_matches? do |l, lc| l == lc end end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In: #{DB[:accounts].where(:id=>rodauth.session_value).get(:email)}"} end visit '/create-account' fill_in 'Login', :with=>'foo2@example.com' fill_in 'Confirm Login', :with=>'FOO2@example.com' fill_in 'Password', :with=>'apple2' fill_in 'Confirm Password', :with=>'apple2' click_button 'Create Account' page.html.must_include("logins do not match") page.find('#error_flash').text.must_equal "There was an error creating your account" page.current_path.must_equal '/create-account' end it "should not display create account link on login page if route is disabled" do route = 'create-account' rodauth do enable :create_account, :login create_account_route { route } end roda do |r| r.rodauth end visit '/login' click_on 'Create a New Account' page.current_path.must_equal '/create-account' route = nil visit '/login' page.html.wont_include "Create a New Account" end [:jwt, :json].each do |json| it "should support creating accounts via #{json}" do rodauth do enable :login, :create_account after_create_account{json_response[:account_id] = account_id} create_account_autologin? false end roda(json) do |r| r.rodauth end res = json_request('/create-account', :login=>'foo@example.com', "login-confirm"=>'foo@example.com', :password=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [422, {'reason'=>"already_an_account_with_this_login",'error'=>"There was an error creating your account", "field-error"=>["login", "invalid login, already an account with this login"]}] res = json_request('/create-account', :login=>'f', "login-confirm"=>'f', :password=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [422, {'reason'=>"login_too_short",'error'=>"There was an error creating your account", "field-error"=>["login", "invalid login, minimum 3 characters"]}] res = json_request('/create-account', :login=>'f'*256, "login-confirm"=>'f'*256, :password=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [422, {'reason'=>"login_too_long",'error'=>"There was an error creating your account", "field-error"=>["login", "invalid login, maximum 255 characters"]}] res = json_request('/create-account', :login=>'foobar', "login-confirm"=>'foobar', :password=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [422, {'reason'=>"login_not_valid_email",'error'=>"There was an error creating your account", "field-error"=>["login", "invalid login, not a valid email address"]}] res = json_request('/create-account', :login=>'foo@example2.com', "login-confirm"=>'foobar', :password=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [422, {'reason'=>"logins_do_not_match",'error'=>"There was an error creating your account", "field-error"=>["login", "logins do not match"]}] res = json_request('/create-account', :login=>'foo@example2.com', "login-confirm"=>'foo@example2.com', :password=>'012345678', "password-confirm"=>'0123456789') res.must_equal [422, {'reason'=>"passwords_do_not_match",'error'=>"There was an error creating your account", "field-error"=>["password", "passwords do not match"]}] res = json_request('/create-account', :login=>'foo@example2.com', "login-confirm"=>'foo@example2.com', :password=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [200, {'success'=>"Your account has been created", 'account_id'=>DB[:accounts].where(:email=>'foo@example2.com').get(:id)}] json_login(:login=>'foo@example2.com') end end it "should support creating accounts using an internal request" do rodauth do enable :login, :create_account, :internal_request end roda do |r| r.rodauth r.root{rodauth.logged_in?.nil?.to_s} end proc do app.rodauth.create_account(:login=>'foo', :password=>'sdkjnlsalkklsda') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.create_account(:login=>'foo3@example.com', :password=>'123') end.must_raise Rodauth::InternalRequestError app.rodauth.create_account(:login=>'foo3@example.com', :password=>'sdkjnlsalkklsda').must_be_nil login(:login=>'foo3@example.com', :pass=>'sdkjnlsalkklsda') page.current_path.must_equal '/' page.body.must_equal 'false' end end jeremyevans-rodauth-b53f402/spec/disallow_common_passwords_spec.rb000066400000000000000000000063711515725514200256370ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth disallow common passwords feature' do it "should check that password used is not one of the most common" do rodauth do enable :login, :change_password, :disallow_common_passwords change_password_requires_password? false password_minimum_length 1 end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-password' bad_password_file = File.join(File.dirname(File.dirname(File.expand_path(__FILE__))), 'dict', 'top-10_000-passwords.txt') (File.read(bad_password_file).split.shuffle - ['0123456789']).take(5).each do |pass| fill_in 'New Password', :with=>pass fill_in 'Confirm Password', :with=>pass click_button 'Change Password' page.html.must_include("invalid password, does not meet requirements (is one of the most common passwords)") page.find('#error_flash').text.must_equal "There was an error changing your password" end fill_in 'New Password', :with=>'footpassword' fill_in 'Confirm Password', :with=>'footpassword' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end it "should check that password used is not one of the most common with custom password set" do rodauth do enable :login, :change_password, :disallow_common_passwords change_password_requires_password? false most_common_passwords ['foobarbaz'] end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-password' fill_in 'New Password', :with=>'foobarbaz' fill_in 'Confirm Password', :with=>'foobarbaz' click_button 'Change Password' page.html.must_include("invalid password, does not meet requirements (is one of the most common passwords)") page.find('#error_flash').text.must_equal "There was an error changing your password" fill_in 'New Password', :with=>'footpassword' fill_in 'Confirm Password', :with=>'footpassword' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end it "should check that password used is not one of the most common with custom check" do rodauth do enable :login, :change_password, :disallow_common_passwords change_password_requires_password? false most_common_passwords_file nil password_one_of_most_common? do |password| password == 'foobarbaz' end end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-password' fill_in 'New Password', :with=>'foobarbaz' fill_in 'Confirm Password', :with=>'foobarbaz' click_button 'Change Password' page.html.must_include("invalid password, does not meet requirements (is one of the most common passwords)") page.find('#error_flash').text.must_equal "There was an error changing your password" fill_in 'New Password', :with=>'footpassword' fill_in 'Confirm Password', :with=>'footpassword' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end end jeremyevans-rodauth-b53f402/spec/disallow_password_reuse_spec.rb000066400000000000000000000161601515725514200253040ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth disallow_password_reuse feature' do [true, false].each do |before| it "should disallow reuse of passwords, when loading disallow_password_reuse #{before ? "before" : "after"}" do table = :account_previous_password_hashes rodauth do features = [:close_account, :disallow_password_reuse] features.reverse! if before enable :login, :change_password, *features if ENV['RODAUTH_SEPARATE_SCHEMA'] table = Sequel[:rodauth_test_password][:account_previous_password_hashes] previous_password_hash_table table end change_password_requires_password? false close_account_requires_password? false end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' 8.times do |i| visit '/change-password' fill_in 'New Password', :with=>"password#{i}" fill_in 'Confirm Password', :with=>"password#{i}" click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end visit '/change-password' (1..6).each do |i| fill_in 'New Password', :with=>"password#{i}" fill_in 'Confirm Password', :with=>"password#{i}" click_button 'Change Password' page.html.must_include("invalid password, does not meet requirements (same as previous password)") page.find('#error_flash').text.must_equal "There was an error changing your password" end fill_in 'New Password', :with=>"password7" fill_in 'Confirm Password', :with=>"password7" click_button 'Change Password' page.html.must_include("invalid password, same as current password") fill_in 'New Password', :with=>'password0' fill_in 'Confirm Password', :with=>'password0' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" DB[table].get{count(:id)}.must_equal 7 visit '/close-account' click_button 'Close Account' DB[table].get{count(:id)}.must_equal 0 end end [true, false].each do |ph| it "should handle create account when account_password_hash_column is #{ph}" do rodauth do features = [:create_account, :disallow_password_reuse] features.reverse! if ph enable :login, :change_password, *features if ENV['RODAUTH_SEPARATE_SCHEMA'] previous_password_hash_table Sequel[:rodauth_test_password][:account_previous_password_hashes] end account_password_hash_column :ph if ph change_password_requires_password? false end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' fill_in 'Login', :with=>'bar@example.com' fill_in 'Confirm Login', :with=>'bar@example.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.current_path.must_equal '/' page.find('#notice_flash').text.must_equal "Your account has been created" visit '/change-password' fill_in 'New Password', :with=>"012345678" fill_in 'Confirm Password', :with=>"012345678" click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" visit '/change-password' fill_in 'New Password', :with=>"0123456789" fill_in 'Confirm Password', :with=>"0123456789" click_button 'Change Password' page.html.must_include("invalid password, does not meet requirements (same as previous password)") end it "should handle verify account when account_password_hash_column is #{ph}" do rodauth do features = [:verify_account, :disallow_password_reuse] features.reverse! if ph enable :login, :change_password, *features if ENV['RODAUTH_SEPARATE_SCHEMA'] previous_password_hash_table Sequel[:rodauth_test_password][:account_previous_password_hashes] end account_password_hash_column :ph if ph change_password_requires_password? false end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' fill_in 'Login', :with=>'bar@example.com' click_button 'Create Account' page.current_path.must_equal '/' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" link = email_link(/(\/verify-account\?key=.+)$/, 'bar@example.com') visit link fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.current_path.must_equal '/' visit '/change-password' fill_in 'New Password', :with=>"012345678" fill_in 'Confirm Password', :with=>"012345678" click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" visit '/change-password' fill_in 'New Password', :with=>"0123456789" fill_in 'Confirm Password', :with=>"0123456789" click_button 'Change Password' page.html.must_include("invalid password, does not meet requirements (same as previous password)") end it "should handle verify account when account_password_hash_column is #{ph} and verify_account_set_password? is true" do rodauth do enable :login, :verify_account, :change_password, :disallow_password_reuse if ENV['RODAUTH_SEPARATE_SCHEMA'] previous_password_hash_table Sequel[:rodauth_test_password][:account_previous_password_hashes] end account_password_hash_column :ph if ph change_password_requires_password? false verify_account_set_password? true end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' fill_in 'Login', :with=>'bar@example.com' click_button 'Create Account' page.current_path.must_equal '/' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" link = email_link(/(\/verify-account\?key=.+)$/, 'bar@example.com') visit link fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.current_path.must_equal '/' visit '/change-password' fill_in 'New Password', :with=>"012345678" fill_in 'Confirm Password', :with=>"012345678" click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" visit '/change-password' fill_in 'New Password', :with=>"0123456789" fill_in 'Confirm Password', :with=>"0123456789" click_button 'Change Password' page.html.must_include("invalid password, does not meet requirements (same as previous password)") end end end jeremyevans-rodauth-b53f402/spec/email_auth_spec.rb000066400000000000000000000320661515725514200224540ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth email auth feature' do it "should support logging in use link sent via email, without a password for the account" do rodauth do enable :login, :email_auth, :logout account_password_hash_column :ph end roda do |r| r.rodauth r.root{view :content=>"Possible Authentication Methods-#{rodauth.possible_authentication_methods.join('/') if rodauth.logged_in?}"} end DB[:accounts].update(:ph=>nil).must_equal 1 visit '/login' fill_in 'Login', :with=>'foo2@example.com' click_button 'Login' page.find('#error_flash').text.must_equal 'There was an error logging in' page.html.must_include("no matching login") fill_in 'Login', :with=>'foo@example.com' click_button 'Login' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to login to your account" page.current_path.must_equal '/' link = email_link(/(\/email-auth\?key=.+)$/) visit '/email-auth' page.find('#error_flash').text.must_equal "There was an error logging you in: invalid email authentication key" visit link[0...-1] page.find('#error_flash').text.must_equal "There was an error logging you in: invalid email authentication key" visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' page.find('#error_flash').text.must_equal "An email has recently been sent to you with a link to login" Mail::TestMailer.deliveries.must_equal [] DB[:account_email_auth_keys].update(:email_last_sent => Time.now - 250).must_equal 1 visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' page.find('#error_flash').text.must_equal "An email has recently been sent to you with a link to login" Mail::TestMailer.deliveries.must_equal [] DB[:account_email_auth_keys].update(:email_last_sent => Time.now - 350).must_equal 1 visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' email_link(/(\/email-auth\?key=.+)$/).must_equal link visit link page.title.must_equal 'Login' click_button 'Login' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' page.html.must_include 'Possible Authentication Methods-email_auth' logout visit link visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' link2 = email_link(/(\/email-auth\?key=.+)$/) link2.wont_equal link visit link2 DB[:account_email_auth_keys].update(:deadline => Time.now - 60).must_equal 1 click_button 'Login' page.find('#error_flash').text.must_equal "There was an error logging you in" page.current_path.must_equal '/' DB[:account_email_auth_keys].count.must_equal 0 visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' visit email_link(/(\/email-auth\?key=.+)$/) DB[:account_email_auth_keys].update(:key=>'1').must_equal 1 click_button 'Login' page.find('#error_flash').text.must_equal "There was an error logging you in" page.current_path.must_equal '/' end it "should support logging in use link sent via email, with a password for the account" do rodauth do enable :login, :email_auth, :logout email_auth_email_last_sent_column nil end roda do |r| r.rodauth r.root{view :content=>"Possible Authentication Methods-#{rodauth.possible_authentication_methods.join('/') if rodauth.logged_in?}"} end visit '/login' fill_in 'Login', :with=>'foo2@example.com' click_button 'Login' page.find('#error_flash').text.must_equal 'There was an error logging in' page.html.must_include("no matching login") fill_in 'Login', :with=>'foo@example.com' click_button 'Login' click_button 'Send Login Link Via Email' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to login to your account" page.current_path.must_equal '/' link = email_link(/(\/email-auth\?key=.+)$/) visit link[0...-1] page.find('#error_flash').text.must_equal "There was an error logging you in: invalid email authentication key" visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' click_button 'Send Login Link Via Email' email_link(/(\/email-auth\?key=.+)$/).must_equal link visit link page.title.must_equal 'Login' click_button 'Login' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' page.html.must_include 'Possible Authentication Methods-password' logout visit link visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' click_button 'Send Login Link Via Email' link2 = email_link(/(\/email-auth\?key=.+)$/) link2.wont_equal link visit link2 DB[:account_email_auth_keys].update(:deadline => Time.now - 60).must_equal 1 click_button 'Login' page.find('#error_flash').text.must_equal "There was an error logging you in" page.current_path.must_equal '/' DB[:account_email_auth_keys].count.must_equal 0 visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' click_button 'Send Login Link Via Email' visit email_link(/(\/email-auth\?key=.+)$/) DB[:account_email_auth_keys].update(:key=>'1').must_equal 1 click_button 'Login' page.find('#error_flash').text.must_equal "There was an error logging you in" page.current_path.must_equal '/' end it "should allow password login for accounts with password hashes" do rodauth do enable :login, :email_auth end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end visit '/login' page.title.must_equal 'Login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' page.html.must_include 'Send Login Link Via Email' fill_in 'Password', :with=>'012345678' click_button 'Login' page.find('#error_flash').text.must_equal "There was an error logging in" page.html.must_include("invalid password") page.html.must_include 'Send Login Link Via Email' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.current_path.must_equal '/' page.find('#notice_flash').text.must_equal 'You have been logged in' end it "should work with creating accounts without setting passwords" do rodauth do enable :login, :create_account, :email_auth require_login_confirmation? false create_account_autologin? false create_account_set_password? false end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "Your account has been created" visit '/login' fill_in 'Login', :with=>'foo@example2.com' click_button 'Login' page.current_path.must_equal '/' visit email_link(/(\/email-auth\?key=.+)$/, 'foo@example2.com') page.title.must_equal 'Login' click_button 'Login' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' end it "should allow returning to requested location when login was required" do rodauth do enable :login, :email_auth login_return_to_requested_location? true force_email_auth? true end roda do |r| r.rodauth r.root{view :content=>""} r.get('page') do rodauth.require_login view :content=>"" end end visit "/page" fill_in 'Login', :with=>'foo@example.com' click_button 'Login' link = email_link(/(\/email-auth\?key=.+)$/) visit link click_button 'Login' page.current_path.must_equal "/page" end [true, false].each do |before| it "should clear email auth token when closing account, when loading email_auth #{before ? "before" : "after"}" do rodauth do features = [:close_account, :email_auth] features.reverse! if before enable :login, *features end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end visit '/login' page.title.must_equal 'Login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' click_button 'Send Login Link Via Email' hash = DB[:account_email_auth_keys].first visit email_link(/(\/email-auth\?key=.+)$/) click_button 'Login' DB[:account_email_auth_keys].count.must_equal 0 DB[:account_email_auth_keys].insert(hash) visit '/close-account' fill_in 'Password', :with=>'0123456789' click_button 'Close Account' DB[:account_email_auth_keys].count.must_equal 0 end end it "should handle uniqueness errors raised when inserting email auth token" do rodauth do enable :login, :email_auth end roda do |r| def rodauth.raised_uniqueness_violation(*) super; true; end r.rodauth r.root{view :content=>""} end visit '/login' page.title.must_equal 'Login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' click_button 'Send Login Link Via Email' link = email_link(/(\/email-auth\?key=.+)$/) DB[:account_email_auth_keys].update(:email_last_sent => Time.now - 350).must_equal 1 visit '/login' page.title.must_equal 'Login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' click_button 'Send Login Link Via Email' email_link(/(\/email-auth\?key=.+)$/).must_equal link end it "should reraise uniqueness errors raised when inserting email auth token, when token not available" do rodauth do enable :login, :email_auth end roda do |r| def rodauth.raised_uniqueness_violation(*, &_) StandardError.new; end r.rodauth r.root{view :content=>""} end visit '/login' page.title.must_equal 'Login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' proc{click_button 'Send Login Link Via Email'}.must_raise StandardError end [:jwt, :json].each do |json| it "should support email auth for accounts via #{json}" do rodauth do enable :login, :email_auth email_auth_email_body{email_auth_email_link} end roda(json) do |r| r.rodauth end res = json_request('/email-auth-request') res.must_equal [401, {"reason"=>"no_matching_login", "error"=>"There was an error requesting an email link to authenticate"}] res = json_request('/email-auth-request', :login=>'foo@example2.com') res.must_equal [401, {"reason"=>"no_matching_login", "error"=>"There was an error requesting an email link to authenticate"}] res = json_request('/email-auth-request', :login=>'foo@example.com') res.must_equal [200, {"success"=>"An email has been sent to you with a link to login to your account"}] link = email_link(/key=.+$/) res = json_request('/email-auth') res.must_equal [401, {"reason"=>"invalid_email_auth_key", "error"=>"There was an error logging you in"}] res = json_request('/email-auth', :key=>link[4...-1]) res.must_equal [401, {"reason"=>"invalid_email_auth_key", "error"=>"There was an error logging you in"}] res = json_request('/email-auth', :key=>link[4..-1]) res.must_equal [200, {"success"=>"You have been logged in"}] end end it "should allow checking email auth using internal requests" do rodauth do enable :login, :logout, :email_auth, :internal_request domain 'example.com' end roda do |r| r.rodauth r.root{view :content=>""} end visit '/login' page.title.must_equal 'Login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' click_button 'Send Login Link Via Email' link = email_link(/(\/email-auth\?key=.+)$/) key = link.split('=').last app.rodauth.valid_email_auth?(:email_auth_key=>key[0...-1]).must_equal false app.rodauth.valid_email_auth?(:email_auth_key=>key).must_equal true visit link page.find('#error_flash').text.must_equal "There was an error logging you in: invalid email authentication key" app.rodauth.email_auth_request(:account_login=>'foo@example.com').must_be_nil link2 = email_link(/(\/email-auth\?key=.+)$/) link2.wont_equal link key = link2.split('=').last proc do app.rodauth.email_auth(:email_auth_key=>key[0...-1]) end.must_raise Rodauth::InternalRequestError app.rodauth.email_auth(:email_auth_key=>key).must_equal DB[:accounts].get(:id) visit link page.find('#error_flash').text.must_equal "There was an error logging you in: invalid email authentication key" app.rodauth.email_auth_request(:login=>'foo@example.com').must_be_nil link3 = email_link(/(\/email-auth\?key=.+)$/) link3.wont_equal link link3.wont_equal link2 visit link3 page.title.must_equal 'Login' click_button 'Login' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' end end jeremyevans-rodauth-b53f402/spec/http_basic_auth_spec.rb000066400000000000000000000172231515725514200235030ustar00rootroot00000000000000require_relative 'spec_helper' describe "Rodauth http basic auth feature" do def basic_auth_visit(opts={}) page.driver.browser.basic_authorize(opts.fetch(:username,"foo@example.com"), opts.fetch(:password, "0123456789")) visit(opts.fetch(:path, '/')) end def authorization_header(opts={}) ["#{opts.delete(:username)||'foo@example.com'}:#{opts.delete(:password)||'0123456789'}"].pack("m0") end def basic_auth_json_request(opts={}) auth = opts.delete(:auth) || authorization_header(opts) path = opts.delete(:path) || '/' json_request(path, opts.merge(:headers => {"HTTP_AUTHORIZATION" => "Basic #{auth}"}, :method=>'GET')) end def newline_basic_auth_json_request(opts={}) auth = opts.delete(:auth) || authorization_header(opts) auth.chomp! basic_auth_json_request(opts.merge(:auth => auth)) end [true, false].each do |set_http_basic_auth| it "should support HTTP basic authentication #{set_http_basic_auth ? "when calling http_basic_auth explicitly" : "when calling logged_in?" }" do rodauth do enable :http_basic_auth end roda do |r| rodauth.http_basic_auth if set_http_basic_auth if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/' page.html.must_include "Not Logged In" page.status_code.must_equal 200 page.driver.browser.header("Authorization", "Bearer abc123") page.html.must_include "Not Logged In" page.status_code.must_equal 200 basic_auth_visit(:username => "foo2@example.com") page.html.must_include "Not Logged In" page.response_headers["WWW-Authenticate"].must_be_kind_of String page.status_code.must_equal 401 page.html.must_include "Not Logged In" basic_auth_visit(:password => "1111111111") page.html.must_include "Not Logged In" page.response_headers["WWW-Authenticate"].must_be_kind_of String page.status_code.must_equal 401 page.html.must_include "Not Logged In" basic_auth_visit page.html.must_include "Logged In via password" page.status_code.must_equal 200 visit '/' page.html.must_include "Logged In via password" page.status_code.must_equal 200 end end it "should support requiring HTTP basic authentication" do rodauth do enable :http_basic_auth end roda do |r| rodauth.require_http_basic_auth rodauth.require_http_basic_auth if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/' page.response_headers["WWW-Authenticate"].must_be_kind_of String page.status_code.must_equal 401 page.html.must_equal '' basic_auth_visit page.html.must_include "Logged In via password" page.status_code.must_equal 200 visit '/' page.html.must_include "Logged In via password" page.status_code.must_equal 200 end it "requires HTTP basic authentication when require_http_basic_auth? is true" do rodauth do enable :http_basic_auth require_http_basic_auth? true end roda do |r| rodauth.require_authentication if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/' page.status_code.must_equal 401 page.response_headers["WWW-Authenticate"].must_be_kind_of String basic_auth_visit page.html.must_include "Logged In via password" end it "requires HTTP basic authentication when require_http_basic_auth? is true even if already logged in" do rodauth do enable :http_basic_auth require_http_basic_auth? true end roda do |r| r.get('login'){session[rodauth.session_key] = 1; 'l'} rodauth.require_authentication if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/login' page.html.must_equal 'l' visit '/' page.status_code.must_equal 401 page.response_headers["WWW-Authenticate"].must_be_kind_of String page.html.must_equal '' basic_auth_visit page.html.must_include "Logged In via password" end it "allows HTTP basic authentication when require_http_basic_auth? is false" do rodauth do enable :login, :http_basic_auth end roda do |r| r.rodauth rodauth.require_authentication if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/' page.html.must_include "Please login to continue" basic_auth_visit page.html.must_include "Logged In via password" end it "should support re-authenticating without logging out" do rodauth do enable :http_basic_auth account_password_hash_column :ph end roda do |r| rodauth.http_basic_auth if rodauth.logged_in? view :content=>"Logged In as #{rodauth.account_from_session[:email]}" else view :content=>"Not Logged In" end end hash = BCrypt::Password.create('0123456789', :cost=>BCrypt::Engine::MIN_COST) DB[:accounts].insert(:email=>'bar@example.com', :status_id=>2, :ph=>hash) basic_auth_visit page.html.must_include "Logged In as foo@example.com" basic_auth_visit(username: "bar@example.com") page.html.must_include "Logged In as bar@example.com" visit "/" page.html.must_include "Logged In as bar@example.com" end it "works with standard authentication" do rodauth do enable :login, :http_basic_auth end roda do |r| r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : 'Not Logged In')} end login page.html.must_include "Logged In" end it "does not allow login to unverified account" do rodauth do enable :http_basic_auth skip_status_checks? false end roda do |r| rodauth.http_basic_auth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : 'Not Logged In')} end DB[:accounts].update(:status_id=>1) basic_auth_visit page.html.must_include "Not Logged In" page.response_headers["WWW-Authenticate"].must_be_kind_of String end [:jwt, :json].each do |json| it "should login via #{json}" do rodauth do enable :http_basic_auth end roda(json) do |r| rodauth.http_basic_auth response[CONTENT_TYPE_KEY] = 'application/json' rodauth.require_authentication {"success"=>'You have been logged in'} end @authorization = nil res = basic_auth_json_request(:auth=>'.') res.must_equal [401, {"reason"=>"login_required", 'error'=>"Please login to continue"}] @authorization = nil res = basic_auth_json_request(:username=>'foo@example2.com') res.must_equal [401, {"reason"=>"login_required", 'error'=>"Please login to continue", "field-error"=>["login", "no matching login"]}] @authorization = nil res = basic_auth_json_request(:password=>'012345678') res.must_equal [401, {"reason"=>"login_required", 'error'=>"Please login to continue", "field-error"=>["password", "invalid password"]}] @authorization = nil res = newline_basic_auth_json_request res.must_equal [200, {"success"=>'You have been logged in'}] @authorization = nil res = basic_auth_json_request res.must_equal [200, {"success"=>'You have been logged in'}] end end end jeremyevans-rodauth-b53f402/spec/json_spec.rb000066400000000000000000000163601515725514200213140ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth json feature' do it "should require json request content type in only json mode for rodauth endpoints only" do oj = false rodauth do enable :login, :logout, :json json_response_success_key 'success' json_response_custom_error_status? false only_json?{oj} end roda(:json=>true) do |r| r.rodauth rodauth.require_authentication '1' end status, headers, body = res = json_request("/", :content_type=>'application/x-www-form-urlencoded', :include_headers=>true, :method=>'GET') status.must_equal 302 header(res, 'Set-Cookie').must_be_kind_of String header(res, "Content-Type").must_equal 'text/html' header(res, "Content-Length").must_equal '0' header(res, "Location").must_equal '/login' headers.length.must_equal 4 body.must_equal "" res = json_request("/", :content_type=>'application/vnd.api+json', :method=>'GET') res.must_equal [400, '{"error":"Please login to continue"}'] oj = true res = json_request("/", :content_type=>'application/x-www-form-urlencoded', :method=>'GET') res.must_equal [400, '{"error":"Please login to continue"}'] res = json_request("/", :method=>'GET') res.must_equal [400, {'error'=>'Please login to continue'}] status, headers, body = res = json_request("/login", :content_type=>'application/x-www-form-urlencoded', :include_headers=>true, :method=>'GET') msg = "Only JSON format requests are allowed" status.must_equal 400 header(res, "Content-Type").must_equal 'text/html' header(res, "Content-Length").must_equal msg.length.to_s headers.length.must_equal 2 body.must_equal msg json_login status, headers, body = res = json_request("/", :content_type=>'application/x-www-form-urlencoded', :include_headers=>true, :method=>'GET') status.must_equal 200 headers.delete('Set-Cookie') header(res, "Content-Type").must_equal 'text/html' header(res, "Content-Length").must_equal '1' headers.length.must_equal 2 body.must_equal '1' end it "should allow non-json requests if only_json? is false" do rodauth do enable :login, :logout end roda(:json_html) do |r| r.rodauth rodauth.require_authentication view(:content=>'1') end login page.find('#notice_flash').text.must_equal 'You have been logged in' end it "should require POST for json requests" do rodauth do enable :login, :logout json_response_success_key 'success' end roda(:json) do |r| r.rodauth end res = json_request("/login", :method=>'GET') res.must_equal [405, {'error'=>'non-POST method used in JSON API'}] end it "should allow customizing JSON response bodies" do rodauth do enable :login, :logout json_response_body do |hash| super('status'=>response.status, 'detail'=>hash) end end roda(:json) do |r| r.rodauth end res = json_request("/login", :method=>'GET') res.must_equal [405, {'status'=>405, 'detail'=>{'error'=>'non-POST method used in JSON API'}}] end it "should require Accept contain application/json if json_check_accept? is true and Accept is present" do rodauth do enable :login, :logout json_response_success_key 'success' json_check_accept? true end roda(:json) do |r| r.rodauth end res = json_request("/login", :headers=>{'HTTP_ACCEPT'=>'text/html'}) res.must_equal [406, {'error'=>'Unsupported Accept header. Must accept "application/json" or compatible content type'}] json_request("/login", :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] json_request("/login", :headers=>{'HTTP_ACCEPT'=>'*/*'}, :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] json_request("/login", :headers=>{'HTTP_ACCEPT'=>'application/*'}, :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] json_request("/login", :headers=>{'HTTP_ACCEPT'=>'application/vnd.api+json'}, :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] end it "should not check CSRF for json requests" do rodauth do enable :login, :json only_json? false end roda(:json_html) do |r| r.rodauth view(:content=>'1') end res = json_request("/login", :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] res.must_equal true end it "should have field error and error flash work correctly when using json feature for non-json requests" do mpl = false rodauth do enable :login, :logout json_response_success_key nil use_multi_phase_login?{mpl} end roda(:json_html) do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end login(:pass=>'012345678') page.find('#error_flash').text.must_equal 'There was an error logging in' page.html.must_include("invalid password") fill_in 'Password', :with=>'0123456789' click_button 'Login' page.current_path.must_equal '/' page.find('#notice_flash').text.must_equal 'You have been logged in' page.html.must_include("Logged In") visit '/logout' page.title.must_equal 'Logout' mpl = true click_button 'Logout' page.find('#notice_flash').text.must_equal 'You have been logged out' page.current_path.must_equal '/login' visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' page.find('#notice_flash').text.must_equal 'Login recognized, please enter your password' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.find('#notice_flash').text.must_equal 'You have been logged in' page.html.must_include("Logged In") visit '/logout' page.title.must_equal 'Logout' click_button 'Logout' json_login(:no_check=>true).must_equal [200, {}] res = json_request('/login', :login=>'foo@example.com') res.must_equal [200, {}] end it "should work with internal requests if only_json? is true" do rodauth do enable :login, :create_account, :internal_request, :json only_json? true end roda(:json=>true) do |r| r.rodauth end app.rodauth.create_account(:login=>'bar@example.com', :password=>'secret') app.rodauth.valid_login_and_password?(:login=>'bar@example.com', :password=>'secret').must_equal true end it "should support json_response_error? method for setting json response status" do rodauth do enable :login, :json json_response_error_key :message json_response_success_key :message json_response_field_error_key :errors json_response_error? { json_response[json_response_field_error_key] } end roda(:json=>true) do |r| r.rodauth end json_request("/login", :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"message"=>'You have been logged in'}] json_request("/login", :login=>'wrong_emil@example.om', :password=>'0123456789').must_equal [401, {"errors"=>["login", "no matching login"], "message"=>"There was an error logging in"}] end end jeremyevans-rodauth-b53f402/spec/jwt_cors_spec.rb000066400000000000000000000045101515725514200221670ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth jwt_cors feature' do it "should support CORS logins if allowed" do origin = false rodauth do enable :login, :jwt_cors jwt_secret '1' json_response_success_key 'success' jwt_cors_allow_origin{origin} end roda(:csrf=>false, :json=>true) do |r| r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' '1' end # CORS Preflight Request preflight_request = { :method=>'OPTIONS', :headers=>{ "HTTP_ACCESS_CONTROL_REQUEST_METHOD"=>"POST", "HTTP_ORIGIN"=>"https://foo.example.com", "HTTP_ACCESS_CONTROL_REQUEST_HEADERS"=>"content-type", "CONTENT_TYPE"=>' application/json' } } res = json_request("/login", preflight_request.dup) res.must_equal [405, "{\"error\":\"non-POST method used in JSON API\"}"] origin = Object.new res = json_request("/login", preflight_request.dup) res.must_equal [405, "{\"error\":\"non-POST method used in JSON API\"}"] req = preflight_request.dup req[:headers] = req[:headers].dup req[:headers].delete('HTTP_ORIGIN') res = json_request("/login", req) res.must_equal [405, "{\"error\":\"non-POST method used in JSON API\"}"] ["https://foo.example.com", ["https://foo.example.com"], %r{https://foo.example.com}, true].each do |orig| origin = orig res = json_request("/login", preflight_request.merge(:include_headers=>true)) res[0].must_equal 204 header(res, 'Access-Control-Allow-Origin').must_equal "https://foo.example.com" header(res, 'Access-Control-Allow-Methods').must_equal "POST" header(res, 'Access-Control-Allow-Headers').must_equal "Content-Type, Authorization, Accept" header(res, 'Access-Control-Max-Age').must_equal "86400" res[2].must_equal "" res = json_request("/login", :login=>'foo@example.com', :password=>'0123456789', :headers=>{"HTTP_ORIGIN"=>"https://foo.example.com"}, :include_headers=>true) res[0].must_equal 200 header(res, 'Access-Control-Allow-Origin').must_equal "https://foo.example.com" header(res, 'Access-Control-Expose-Headers').must_equal "Authorization" res[2].must_equal("success"=>"You have been logged in") json_request("/foo").must_equal [200, 1] end end end jeremyevans-rodauth-b53f402/spec/jwt_refresh_spec.rb000066400000000000000000000613421515725514200226650ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth login feature' do it "should not have jwt refresh feature assume JWT token given during Basic/Digest authentication" do rodauth do enable :login, :jwt_refresh end roda(:jwt) do |r| rodauth.require_authentication '1' end res = json_request("/jwt-refresh", :headers=>{'HTTP_AUTHORIZATION'=>'Basic foo'}) res.must_equal [401, {"reason"=>"login_required", 'error'=>'Please login to continue'}] res = json_request("/", :headers=>{'HTTP_AUTHORIZATION'=>'Digest foo'}) res.must_equal [401, {"reason"=>"login_required", 'error'=>'Please login to continue'}] end it "should require json request content type in only json mode for rodauth endpoints only" do oj = false rodauth do enable :login, :jwt_refresh jwt_secret '1' json_response_success_key 'success' json_response_custom_error_status? false only_json?{oj} end roda(:csrf=>false, :json=>true) do |r| r.rodauth rodauth.require_authentication '1' end status, headers, body = res = json_request("/", :content_type=>'application/x-www-form-urlencoded', :include_headers=>true, :method=>'GET') status.must_equal 302 header(res, 'Set-Cookie').must_be_kind_of String header(res, "Content-Type").must_equal 'text/html' header(res, "Content-Length").must_equal '0' header(res, "Location").must_equal '/login' headers.length.must_equal 4 body.must_equal '' res = json_request("/", :content_type=>'application/vnd.api+json', :method=>'GET') res.must_equal [400, '{"error":"Please login to continue"}'] oj = true res = json_request("/", :content_type=>'application/x-www-form-urlencoded', :method=>'GET') res.must_equal [400, '{"error":"Please login to continue"}'] res = json_request("/", :method=>'GET') res.must_equal [400, {'error'=>'Please login to continue'}] status, headers, body = res = json_request("/login", :content_type=>'application/x-www-form-urlencoded', :include_headers=>true, :method=>'GET') msg = "Only JSON format requests are allowed" status.must_equal 400 header(res, "Content-Type").must_equal 'text/html' header(res, "Content-Length").must_equal msg.length.to_s headers.length.must_equal 2 body.must_equal msg jwt_refresh_login status, headers, body = res = json_request("/", :content_type=>'application/x-www-form-urlencoded', :include_headers=>true, :method=>'GET') status.must_equal 200 header(res, "Content-Type").must_equal 'text/html' header(res, "Content-Length").must_equal '1' headers.length.must_equal 2 body.must_equal '1' end it "should allow non-json requests if only_json? is false" do rodauth do enable :login, :jwt_refresh jwt_secret '1' only_json? false end roda(:jwt_html) do |r| r.rodauth rodauth.require_authentication view(:content=>'1') end login page.find('#notice_flash').text.must_equal 'You have been logged in' end it "should clear refresh tokens if resetting password when not logged in" do rodauth do enable :login, :jwt_refresh jwt_secret '1' only_json? false end roda(:jwt_html) do |r| r.rodauth rodauth.require_authentication view(:content=>'1') end login page.find('#notice_flash').text.must_equal 'You have been logged in' end it "should clear refresh tokens when resetting password without a logged in session" do rodauth do enable :login, :reset_password, :jwt_refresh require_password_confirmation? false reset_password_autologin? false end roda(:jwt_html) do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In!" : "Not Logged"} end jwt_refresh_login login visit '/login' login(:pass=>'01234567', :visit=>false) click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" remove_cookie('rack.session') visit email_link(/(\/reset-password\?key=.+)$/) fill_in 'Password', :with=>'012345678911' DB[:account_jwt_refresh_keys].count.must_equal 2 click_button "Reset Password" page.find('#notice_flash').text.must_equal "Your password has been reset" page.body.must_include "Not Logged" DB[:account_jwt_refresh_keys].count.must_equal 0 end it "should not clear refresh tokens when changing login in a logged in session" do rodauth do enable :login, :change_login, :jwt_refresh require_login_confirmation? false change_login_requires_password? false end roda(:jwt_html) do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In!" : "Not Logged"} end jwt_refresh_login login visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' DB[:account_jwt_refresh_keys].count.must_equal 2 click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" DB[:account_jwt_refresh_keys].count.must_equal 2 visit '/' page.body.must_include "Logged In!" end it "should require POST for json requests" do rodauth do enable :login, :jwt_refresh jwt_secret '1' json_response_success_key 'success' end roda(:jwt) do |r| r.rodauth end res = json_request("/login", :method=>'GET') res.must_equal [405, {'error'=>'non-POST method used in JSON API'}] end it "should require Accept contain application/json if json_check_accept? is true and Accept is present" do rodauth do enable :login, :jwt_refresh jwt_secret '1' json_response_success_key 'success' json_check_accept? true end roda(:jwt) do |r| r.rodauth end res = json_request("/login", :headers=>{'HTTP_ACCEPT'=>'text/html'}) res.must_equal [406, {'error'=>'Unsupported Accept header. Must accept "application/json" or compatible content type'}] jwt_refresh_validate_login(json_request("/login", :login=>'foo@example.com', :password=>'0123456789')) jwt_refresh_validate_login(json_request("/login", :headers=>{'HTTP_ACCEPT'=>'*/*'}, :login=>'foo@example.com', :password=>'0123456789')) jwt_refresh_validate_login(json_request("/login", :headers=>{'HTTP_ACCEPT'=>'application/*'}, :login=>'foo@example.com', :password=>'0123456789')) jwt_refresh_validate_login(json_request("/login", :headers=>{'HTTP_ACCEPT'=>'application/vnd.api+json'}, :login=>'foo@example.com', :password=>'0123456789')) end [true, false].each do |before| it "should clear jwt refresh token when closing account, when loading jwt_refresh #{before ? "before" : "after"}" do rodauth do features = [:close_account, :jwt_refresh] features.reverse! if before enable :login, *features jwt_secret '1' end roda(:jwt) do |r| r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'hello' => 'world'}.to_json end jwt_refresh_login DB[:account_jwt_refresh_keys].count.must_equal 1 res = json_request('/close-account', :password=>'0123456789') res[1].delete('access_token').must_be_kind_of(String) res.must_equal [200, {'success'=>"Your account has been closed"}] DB[:account_jwt_refresh_keys].count.must_equal 0 end end it "should set refresh tokens when creating accounts when using autologin" do rodauth do enable :login, :create_account, :jwt_refresh after_create_account{json_response[:account_id] = account_id} create_account_autologin? true end roda(:jwt) do |r| r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'hello' => 'world'}.to_json end res = json_request('/create-account', :login=>'foo@example2.com', "login-confirm"=>'foo@example2.com', :password=>'0123456789', "password-confirm"=>'0123456789') refresh_token = res.last.delete('refresh_token') @authorization = res.last.delete('access_token') res.must_equal [200, {'success'=>"Your account has been created", 'account_id'=>DB[:accounts].where(:email=>'foo@example2.com').get(:id)}] res = json_request("/") res.must_equal [200, {'hello'=>'world'}] # We can refresh our token res = json_request("/jwt-refresh", :refresh_token=>refresh_token) jwt_refresh_validate(res) @authorization = res.last.delete('access_token') # Which we can use to access protected resources res = json_request("/") res.must_equal [200, {'hello'=>'world'}] end [false, true].each do |hs| it "generates and refreshes Refresh Tokens #{'with hmac_secret' if hs}" do if hs initial_secret = secret = SecureRandom.random_bytes(32) old_secret = nil end rt = nil rodauth do enable :login, :logout, :jwt_refresh if hs hmac_secret{secret} hmac_old_secret{old_secret} end jwt_secret '1' skip_status_checks? hs after_refresh_token{rt = json_response['refresh_token']} end roda(:jwt) do |r| r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'hello' => 'world'}.to_json end res = json_request("/") res.must_equal [401, {'reason'=>'login_required', 'error'=>'Please login to continue'}] # We can login res = jwt_refresh_login refresh_token = res.last['refresh_token'] # Which gives us an access token which grants us access to protected resources @authorization = res.last['access_token'] res = json_request("/") res.must_equal [200, {'hello'=>'world'}] # We can refresh our token res = json_request("/jwt-refresh", :refresh_token=>refresh_token) jwt_refresh_validate(res) second_refresh_token = res.last['refresh_token'] second_refresh_token.must_equal rt # Which we can use to access protected resources @authorization = res.last['access_token'] res = json_request("/") res.must_equal [200, {'hello'=>'world'}] # Subsequent refresh token is valid res = json_request("/jwt-refresh", :refresh_token=>second_refresh_token) jwt_refresh_validate(res) third_refresh_token = res.last['refresh_token'] # First refresh token is now no longer valid res = json_request("/jwt-refresh", :refresh_token=>refresh_token) res.must_equal [400, {"error"=>"invalid JWT refresh token"}] # Test more invalid token types res = json_request("/jwt-refresh", :refresh_token=>refresh_token.gsub('_', '-')) res.must_equal [400, {"error"=>"invalid JWT refresh token"}] token_parts = refresh_token.split('_', 2) if DB[:accounts].get(:id).is_a?(Integer) res = json_request("/jwt-refresh", :refresh_token=>"#{token_parts[0]}_9223372036854775807#{token_parts[1]}") res.must_equal [400, {"error"=>"invalid JWT refresh token"}] res = json_request("/jwt-refresh", :refresh_token=>"9223372036854775807#{token_parts[0]}_#{token_parts[1]}") res.must_equal [400, {"error"=>"invalid JWT refresh token"}] res = json_request("/jwt-refresh", :refresh_token=>"#{token_parts[0]}_-9223372036854775807#{token_parts[1]}") res.must_equal [400, {"error"=>"invalid JWT refresh token"}] res = json_request("/jwt-refresh", :refresh_token=>"-9223372036854775807#{token_parts[0]}_#{token_parts[1]}") res.must_equal [400, {"error"=>"invalid JWT refresh token"}] res = json_request("/jwt-refresh", :refresh_token=>"v#{token_parts[0]}_#{token_parts[1]}") res.must_equal [400, {"error"=>"invalid JWT refresh token"}] res = json_request("/jwt-refresh", :refresh_token=>"#{token_parts[0]}_v#{token_parts[1]}") res.must_equal [400, {"error"=>"invalid JWT refresh token"}] end # Third refresh token is valid res = json_request("/jwt-refresh", :refresh_token=>third_refresh_token) jwt_refresh_validate(res) fourth_refresh_token = res.last['refresh_token'] # And still gives us a valid access token @authorization = res.last['access_token'] res = json_request("/") res.must_equal [200, {'hello'=>'world'}] # Disallow refresh token usage after logout json_request("/logout", :refresh_token=>fourth_refresh_token).first.must_equal 200 fifth_refresh_token = jwt_refresh_login.last['refresh_token'] json_request("/jwt-refresh", :refresh_token=>fourth_refresh_token).first.must_equal 400 json_request("/logout", :refresh_token=>fifth_refresh_token[0...-1]).first.must_equal 200 jwt_refresh_login json_request("/jwt-refresh", :refresh_token=>fifth_refresh_token).first.must_equal 200 json_request("/logout", :refresh_token=>'all').first.must_equal 200 sixth_refresh_token = jwt_refresh_login.last['refresh_token'] json_request("/jwt-refresh", :refresh_token=>fifth_refresh_token).first.must_equal 400 if hs # Refresh secret doesn't work if hmac_secret changed secret = SecureRandom.random_bytes(32) res = json_request("/jwt-refresh", :refresh_token=>sixth_refresh_token) res.first.must_equal 400 res.must_equal [400, {'error'=>'invalid JWT refresh token'}] # Refresh secret works if hmac_secret changed back secret = initial_secret res = json_request("/jwt-refresh", :refresh_token=>sixth_refresh_token) jwt_refresh_validate(res) # And still gives us a valid access token @authorization = res.last['access_token'] res = json_request("/") res.must_equal [200, {'hello'=>'world'}] seventh_refresh_token = jwt_refresh_login.last['refresh_token'] # Refresh secret works when rotating old_secret = secret secret = SecureRandom.random_bytes(32) res = json_request("/jwt-refresh", :refresh_token=>seventh_refresh_token) jwt_refresh_validate(res) # And still gives us a valid access token @authorization = res.last['access_token'] res = json_request("/") res.must_equal [200, {'hello'=>'world'}] eighth_refresh_token = jwt_refresh_login.last['refresh_token'] # Refresh secret works after rotating old_secret = nil res = json_request("/jwt-refresh", :refresh_token=>eighth_refresh_token) jwt_refresh_validate(res) # Refresh secret doesn't work if neither secret matches secret = SecureRandom.random_bytes(32) old_secret = SecureRandom.random_bytes(32) res = json_request("/jwt-refresh", :refresh_token=>eighth_refresh_token) res.first.must_equal 400 res.must_equal [400, {'error'=>'invalid JWT refresh token'}] end end end [true, false].each do |before| it "prevents usage of previous access tokens after refresh when using active_sessions plugin, when loading jwt_refresh #{before ? "before" : "after"}" do rodauth do features = [:active_sessions, :jwt_refresh] features.reverse! if before enable :login, *features, :logout hmac_secret '123' jwt_secret '1' end roda(:jwt) do |r| r.rodauth rodauth.require_authentication rodauth.check_active_session response[CONTENT_TYPE_KEY] = 'application/json' r.post('reset'){rodauth.session.delete(rodauth.session_id_session_key); rodauth.view(nil, nil)} {'hello' => 'world'}.to_json end res = json_request("/") res.must_equal [401, {'reason'=>'login_required', 'error'=>'Please login to continue'}] res = jwt_refresh_login refresh_token = res.last['refresh_token'] @authorization = pre_refresh_access_token = res.last['access_token'] res = json_request("/") res.must_equal [200, {'hello'=>'world'}] res = json_request("/jwt-refresh", :refresh_token=>refresh_token) jwt_refresh_validate(res) post_refresh_access_token = @authorization @authorization = pre_refresh_access_token res = json_request("/") res.must_equal [401, {'reason'=>'inactive_session', 'error'=>'This session has been logged out'}] @authorization = post_refresh_access_token res = json_request("/") res.must_equal [200, {'hello'=>'world'}] json_request("/logout") res = jwt_refresh_login refresh_token = res.last['refresh_token'] json_request("/reset") res = json_request("/jwt-refresh", :refresh_token=>refresh_token) jwt_refresh_validate(res) end end it "should not return access_token for failed login attempt" do rodauth do enable :login, :create_account, :jwt_refresh after_create_account{json_response[:account_id] = account_id} create_account_autologin? true end roda(:jwt) do |r| r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'hello' => 'world'}.to_json end json_request('/create-account', :login=>'foo@example2.com', "login-confirm"=>'foo@example2.com', :password=>'0123456789', "password-confirm"=>'0123456789') res = json_request('/login', :login=>'foo@example2.com', :password=>'123123') res.must_equal [401, {'reason'=>"invalid_password","field-error"=>['password', 'invalid password'], "error"=>"There was an error logging in"}] end it "should not allow refreshing token without providing access token" do rodauth do enable :login, :logout, :jwt_refresh, :close_account hmac_secret SecureRandom.random_bytes(32) jwt_secret '1' end roda(:jwt) do |r| r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'authenticated_by' => rodauth.authenticated_by}.to_json end res = jwt_refresh_login @authorization = nil res = json_request("/jwt-refresh", :refresh_token=>res.last['refresh_token']) res.must_equal [401, {"error"=>"no JWT access token provided during refresh"}] json_request('/').must_equal [401, {"reason"=>"login_required", "error"=>"Please login to continue"}] end it "should not allow refreshing token when providing expired access token" do period = -2 secret = '1' rodauth do enable :login, :logout, :jwt_refresh, :close_account jwt_secret{secret} jwt_access_token_period{period} expired_jwt_access_token_status 401 end roda(:jwt) do |r| r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'authenticated_by' => rodauth.authenticated_by}.to_json end res = jwt_refresh_login refresh_token = res.last['refresh_token'] period = 1800 res = json_request("/jwt-refresh", :refresh_token=>refresh_token) res.must_equal [401, {"error"=>"expired JWT access token"}] res = json_request('/') res.must_equal [401, {"error"=>"expired JWT access token"}] secret = '2' res = json_request('/') res.must_equal [400, {"error"=>"invalid JWT format or claim in Authorization header"}] end it "should allow refreshing token when providing expired access token if configured" do period = -2 rodauth do enable :login, :logout, :jwt_refresh, :close_account hmac_secret SecureRandom.random_bytes(32) jwt_secret '1' jwt_access_token_period{period} allow_refresh_with_expired_jwt_access_token? true end roda(:jwt) do |r| r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'authenticated_by' => rodauth.authenticated_by}.to_json end res = jwt_refresh_login refresh_token = res.last['refresh_token'] period = 1800 res = json_request("/jwt-refresh", :refresh_token=>refresh_token) jwt_refresh_validate(res) json_request('/').must_equal [200, {"authenticated_by"=>["password"]}] end it "should allow refreshing token when providing expired access token when rotating hmac secret" do period = -2 secret = SecureRandom.random_bytes(32) old_secret = nil rodauth do enable :login, :logout, :jwt_refresh, :close_account hmac_secret{secret} hmac_old_secret{old_secret} jwt_secret '1' jwt_access_token_period{period} allow_refresh_with_expired_jwt_access_token? true end roda(:jwt) do |r| r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'authenticated_by' => rodauth.authenticated_by}.to_json end res = jwt_refresh_login refresh_token = res.last['refresh_token'] period = 1800 old_secret = secret secret = SecureRandom.random_bytes(32) res = json_request("/jwt-refresh", :refresh_token=>refresh_token) jwt_refresh_validate(res) json_request('/').must_equal [200, {"authenticated_by"=>["password"]}] end it "should not allow refreshing token when providing expired access token when rotating hmac secret with invalid old secret" do period = -2 secret = SecureRandom.random_bytes(32) old_secret = nil rodauth do enable :login, :logout, :jwt_refresh, :close_account hmac_secret{secret} hmac_old_secret{old_secret} jwt_secret '1' jwt_access_token_period{period} allow_refresh_with_expired_jwt_access_token? true end roda(:jwt) do |r| r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'authenticated_by' => rodauth.authenticated_by}.to_json end res = jwt_refresh_login refresh_token = res.last['refresh_token'] period = 1800 old_secret = SecureRandom.random_bytes(32) secret = SecureRandom.random_bytes(32) res = json_request("/jwt-refresh", :refresh_token=>refresh_token) res.must_equal [400, {"error"=>"invalid JWT refresh token"}] end it "should allow refreshing token when providing expired access token if configured and prefix is not correct" do period = -2 rodauth do enable :login, :logout, :jwt_refresh, :close_account hmac_secret SecureRandom.random_bytes(32) jwt_secret '1' jwt_access_token_period{period} allow_refresh_with_expired_jwt_access_token? true end roda(:jwt) do |r| r.on 'auth' do r.rodauth end rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'authenticated_by' => rodauth.authenticated_by}.to_json end res = jwt_refresh_login(:path=>'/auth/login') refresh_token = res.last['refresh_token'] period = 1800 res = json_request("/auth/jwt-refresh", :refresh_token=>refresh_token) jwt_refresh_validate(res) json_request('/').must_equal [200, {"authenticated_by"=>["password"]}] end it "should allow refreshing token when providing expired access token if configured with active_sessions" do period = -2 rodauth do enable :active_sessions, :login, :logout, :jwt_refresh, :close_account hmac_secret SecureRandom.random_bytes(32) jwt_secret '1' jwt_access_token_period{period} allow_refresh_with_expired_jwt_access_token? true end roda(:jwt) do |r| rodauth.check_active_session r.rodauth rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'authenticated_by' => rodauth.authenticated_by}.to_json end res = jwt_refresh_login refresh_token = res.last['refresh_token'] period = 1800 res = json_request("/jwt-refresh", :refresh_token=>refresh_token) jwt_refresh_validate(res) json_request('/').must_equal [200, {"authenticated_by"=>["password"]}] end it "should allow refreshing token for unverified accounts in grace period" do rodauth do enable :verify_account_grace_period, :login, :logout, :jwt_refresh hmac_secret SecureRandom.random_bytes(32) jwt_secret '1' require_password_confirmation? false end roda(:jwt_html) do |r| r.rodauth if rodauth.json_request? rodauth.require_authentication response[CONTENT_TYPE_KEY] = 'application/json' {'authenticated_by' => rodauth.authenticated_by}.to_json else r.root{view :content=>"Authenticated? #{!!rodauth.authenticated?}"} end end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'123456789' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') page.body.must_include('Authenticated? true') res = jwt_refresh_login(:login=>'foo@example2.com', :pass=>'123456789') refresh_token = res.last['refresh_token'] res = json_request("/jwt-refresh", :refresh_token=>refresh_token) jwt_refresh_validate(res) json_request('/').must_equal [200, {"authenticated_by"=>["password"]}] end end jeremyevans-rodauth-b53f402/spec/jwt_spec.rb000066400000000000000000000203301515725514200211370ustar00rootroot00000000000000require_relative 'spec_helper' require 'jwt/version' describe 'Rodauth login feature' do it "should not have jwt feature assume JWT token given during Basic/Digest authentication" do rodauth do enable :login, :logout end roda(:jwt) do |r| rodauth.require_authentication '1' end res = json_request("/", :headers=>{'HTTP_AUTHORIZATION'=>'Basic foo'}) res.must_equal [401, {"reason"=>"login_required", 'error'=>'Please login to continue'}] res = json_request("/", :headers=>{'HTTP_AUTHORIZATION'=>'Digest foo'}) res.must_equal [401, {"reason"=>"login_required", 'error'=>'Please login to continue'}] end it "should return error message if invalid JWT format used in request Authorization header" do rodauth do enable :login, :logout end roda(:jwt) do |r| r.rodauth rodauth.require_authentication '1' end res = json_request('/login', :include_headers=>true, :login=>'foo@example.com', :password=>'0123456789') res = json_request("/", :headers=>{'HTTP_AUTHORIZATION'=>header(res, 'Authorization')[1..-1]}) res.must_equal [400, {'error'=>'invalid JWT format or claim in Authorization header'}] end it "should use custom JSON error statuses even if the request isn't in JSON format if a JWT is in use" do rodauth do only_json? true end roda(:jwt) do |r| r.rodauth rodauth.require_authentication '1' end status, headers, body = json_request("/", :headers=>{'CONTENT_TYPE'=>'text/html'}, :include_headers=>true) status.must_equal 401 headers[CONTENT_TYPE_KEY].must_equal 'application/json' JSON.parse(body).must_equal("reason"=>"login_required", "error"=>"Please login to continue") end it "should not check CSRF for json requests" do rodauth do enable :login, :jwt jwt_secret '1' only_json? false end roda(:jwt_html) do |r| r.rodauth view(:content=>'1') end res = json_request("/login", :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] res.must_equal true end it "should allow customizing JSON response bodies if invalid JWT format used in request Authorization header" do rodauth do enable :login, :logout, :jwt json_response_body do |hash| super('status'=>response.status, 'detail'=>hash) end end roda(:jwt) do |r| r.rodauth rodauth.require_authentication '1' end res = json_request('/login', :include_headers=>true, :login=>'foo@example.com', :password=>'0123456789') res = json_request("/", :headers=>{'HTTP_AUTHORIZATION'=>header(res, 'Authorization')[1..-1]}) res.must_equal [400, {'status'=>400, 'detail'=>{'error'=>'invalid JWT format or claim in Authorization header'}}] end it "should support valid_jwt? method for checking for valid JWT tokens" do rodauth do enable :login, :logout, :jwt jwt_secret '1' json_response_success_key 'success' end roda(:jwt) do |r| r.rodauth [rodauth.valid_jwt?.to_s] end res = json_request("/", :method=>'GET') res.must_equal [200, ['false']] res = json_request("/login", :method=>'GET') res.must_equal [405, {'error'=>'non-POST method used in JSON API'}] res = json_request("/", :method=>'GET') res.must_equal [200, ['true']] end it "should support jwt_old_secret for JWT secret rotation" do secret = '1' old_secret = nil rodauth do enable :login, :logout, :jwt jwt_old_secret{old_secret} jwt_secret{secret} json_response_success_key 'success' end roda(:jwt) do |r| r.rodauth [rodauth.valid_jwt?.to_s] end json_login res = json_request("/", :method=>'GET') res.must_equal [200, ['true']] secret = '2' res = json_request("/", :method=>'GET') res.must_equal [200, ['false']] old_secret = '1' res = json_request("/", :method=>'GET') res.must_equal [200, ['true']] end if JWT.gem_version >= Gem::Version.new('2.4') it "should require Accept contain application/json if jwt_check_accept? is true and Accept is present" do warning = nil rodauth do enable :login, :logout, :jwt jwt_secret '1' json_response_success_key 'success' define_singleton_method(:warn) do |*a| warning = a.first end auth_class_eval do define_method(:warn) do |*a| warning = a.first end private :warn end jwt_check_accept? true end roda(:jwt) do |r| r.rodauth end res = json_request("/login", :headers=>{'HTTP_ACCEPT'=>'text/html'}) res.must_equal [406, {'error'=>'Unsupported Accept header. Must accept "application/json" or compatible content type'}] warning.must_equal "Deprecated jwt_check_accept? method used during configuration, switch to using json_check_accept?" json_request("/login", :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] json_request("/login", :headers=>{'HTTP_ACCEPT'=>'*/*'}, :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] json_request("/login", :headers=>{'HTTP_ACCEPT'=>'application/*'}, :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] json_request("/login", :headers=>{'HTTP_ACCEPT'=>'application/vnd.api+json'}, :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] end it "generates and verifies JWTs with claims" do invalid_jti = false rodauth do enable :login, :logout, :jwt jwt_secret '1' json_response_success_key 'success' jwt_session_key 'data' jwt_symbolize_deeply? true jwt_session_hash do h = super() h['data']['foo'] = {:bar=>[1]} h.merge( :aud => %w[Young Old], :exp => Time.now.to_i + 120, :iat => Time.now.to_i, :iss => "Foobar, Inc.", :jti => SecureRandom.hex(10), :nbf => Time.now.to_i - 30, :sub => session_value ) end jwt_decode_opts( :aud => 'Old', :iss => "Foobar, Inc.", :leeway => 30, :verify_aud => true, :verify_expiration => true, :verify_iat => true, :verify_iss => true, :verify_jti => proc{|jti| invalid_jti ? false : !!jti}, :verify_not_before => true ) end roda(:jwt) do |r| r.rodauth r.post{rodauth.session[:foo][:bar]} end json_login.must_equal [200, {"success"=>'You have been logged in'}] payload = JWT.decode(@authorization, nil, false)[0] payload['sub'].must_equal payload['data']['account_id'] payload['iat'].must_be_kind_of Integer payload['exp'].must_be_kind_of Integer payload['nbf'].must_be_kind_of Integer payload['iss'].must_equal "Foobar, Inc." payload['aud'].must_equal %w[Young Old] payload['jti'].must_match(/^[0-9a-f]{20}$/) json_request.must_equal [200, [1]] invalid_jti = true json_login(:no_check=>true).must_equal [400, {"error"=>'invalid JWT format or claim in Authorization header'}] end it "handles case where there is no data in the session due to use of jwt_session_key" do key = 'data' rodauth do enable :login, :jwt jwt_secret '1' jwt_session_key{key} after_login do session[:foo] = 'bar' end end roda(:jwt) do |r| r.rodauth r.post{[rodauth.session[:foo], rodauth.valid_jwt?]} end json_login.must_equal [200, {"success"=>'You have been logged in'}] json_request[1].must_equal ['bar', true] key = 'data2' json_request[1].must_equal [nil, true] end it "should return empty JWT token after calling #clear_session" do rodauth do enable :login end roda(:jwt_html) do |r| r.rodauth r.post('clear') do rodauth.clear_session rodauth.session end r.post('') do rodauth.session end end json_login res = json_request '/clear', include_headers: true header(res, 'Authorization').wont_be_nil res[2].must_equal({}) res = json_request '/' res[1].must_equal({}) end end jeremyevans-rodauth-b53f402/spec/lib_spec.rb000066400000000000000000000032701515725514200211050ustar00rootroot00000000000000require_relative 'spec_helper' require 'rodauth' describe 'Rodauth.lib' do it "should support returning a Rodauth::Auth class usable as a library" do rodauth = Rodauth.lib do enable :login, :create_account, :change_password if ENV['RODAUTH_SEPARATE_SCHEMA'] password_hash_table Sequel[:rodauth_test_password][:account_password_hashes] function_name do |name| "rodauth_test_password.#{name}" end end if ENV['RODAUTH_ALWAYS_ARGON2'] == '1' enable :argon2 end end rodauth.valid_login_and_password?(:login=>'foo@example.com', :password=>'0123456789').must_equal true rodauth.valid_login_and_password?(:login=>'foo@example.com', :password=>'01234567').must_equal false rodauth.valid_login_and_password?(:login=>'foo3@example.com', :password=>'0123456789').must_equal false rodauth.create_account(:login=>'foo3@example.com', :password=>'sdkjnlsalkklsda').must_be_nil rodauth.valid_login_and_password?(:login=>'foo@example.com', :password=>'0123456789').must_equal true rodauth.valid_login_and_password?(:login=>'foo@example.com', :password=>'01234567').must_equal false rodauth.valid_login_and_password?(:login=>'foo3@example.com', :password=>'sdkjnlsalkklsda').must_equal true rodauth.change_password(:account_login=>'foo@example.com', :password=>'01234567').must_be_nil rodauth.valid_login_and_password?(:login=>'foo@example.com', :password=>'0123456789').must_equal false rodauth.valid_login_and_password?(:login=>'foo@example.com', :password=>'01234567').must_equal true rodauth.valid_login_and_password?(:login=>'foo3@example.com', :password=>'sdkjnlsalkklsda').must_equal true end end jeremyevans-rodauth-b53f402/spec/lockout_spec.rb000066400000000000000000000352371515725514200220270ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth lockout feature' do it "should support account lockouts without autologin on unlock" do lockouts = [] rodauth do enable :lockout max_invalid_logins 2 unlock_account_autologin? false after_account_lockout{lockouts << true} account_lockouts_email_last_sent_column nil end roda do |r| r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged")} end login(:pass=>'012345678910') page.find('#error_flash').text.must_equal 'There was an error logging in' login page.find('#notice_flash').text.must_equal 'You have been logged in' page.body.must_include("Logged In") remove_cookie('rack.session') visit '/login' fill_in 'Login', :with=>'foo@example.com' 2.times do fill_in 'Password', :with=>'012345678910' click_button 'Login' page.find('#error_flash').text.must_equal 'There was an error logging in' end lockouts.must_equal [true] fill_in 'Password', :with=>'012345678910' click_button 'Login' page.find('#error_flash').text.must_equal "This account is currently locked out and cannot be logged in to" page.body.must_include("This account is currently locked out") page.status_code.must_equal 403 click_button 'Request Account Unlock' page.find('#notice_flash').text.must_equal 'An email has been sent to you with a link to unlock your account' link = email_link(/(\/unlock-account\?key=.+)$/) visit '/login' fill_in 'Login', :with=>'foo@example.com' fill_in 'Password', :with=>'012345678910' click_button 'Login' click_button 'Request Account Unlock' email_link(/(\/unlock-account\?key=.+)$/).must_equal link visit '/unlock-account' page.find('#error_flash').text.must_equal "There was an error unlocking your account: invalid or expired unlock account key" visit link[0...-1] page.find('#error_flash').text.must_equal "There was an error unlocking your account: invalid or expired unlock account key" if DB[:accounts].get(:id).is_a?(Integer) visit link.sub('key=', 'key=18446744073709551616') page.find('#error_flash').text.must_equal "There was an error unlocking your account: invalid or expired unlock account key" visit link.sub('key=', 'key=-18446744073709551616') page.find('#error_flash').text.must_equal "There was an error unlocking your account: invalid or expired unlock account key" visit link.sub('key=', 'key=v') page.find('#error_flash').text.must_equal "There was an error unlocking your account: invalid or expired unlock account key" end visit link click_button 'Unlock Account' page.find('#notice_flash').text.must_equal 'Your account has been unlocked' page.body.must_include('Not Logged') login page.find('#notice_flash').text.must_equal 'You have been logged in' page.body.must_include("Logged In") end it "should support account lockouts with autologin and password required on unlock" do rodauth do enable :lockout unlock_account_requires_password? true end roda do |r| r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged")} end visit '/login' fill_in 'Login', :with=>'foo@example.com' 100.times do fill_in 'Password', :with=>'012345678910' click_button 'Login' page.find('#error_flash').text.must_equal 'There was an error logging in' end fill_in 'Password', :with=>'012345678910' click_button 'Login' page.find('#error_flash').text.must_equal "This account is currently locked out and cannot be logged in to" page.body.must_include("This account is currently locked out") click_button 'Request Account Unlock' page.find('#notice_flash').text.must_equal 'An email has been sent to you with a link to unlock your account' link = email_link(/(\/unlock-account\?key=.+)$/) visit '/login' fill_in 'Login', :with=>'foo@example.com' fill_in 'Password', :with=>'012345678910' click_button 'Login' click_button 'Request Account Unlock' page.find('#error_flash').text.must_equal "An email has recently been sent to you with a link to unlock the account" Mail::TestMailer.deliveries.must_equal [] visit link click_button 'Unlock Account' page.find('#error_flash').text.must_equal 'There was an error unlocking your account' page.body.must_include('invalid password') fill_in 'Password', :with=>'0123456789' click_button 'Unlock Account' page.find('#notice_flash').text.must_equal 'Your account has been unlocked' page.body.must_include("Logged In") end it "should autounlock after enough time" do rodauth do enable :lockout max_invalid_logins 2 convert_token_id_to_integer? false end roda do |r| r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged")} end visit '/login' fill_in 'Login', :with=>'foo@example.com' 2.times do fill_in 'Password', :with=>'012345678910' click_button 'Login' page.find('#error_flash').text.must_equal 'There was an error logging in' end fill_in 'Password', :with=>'012345678910' click_button 'Login' page.find('#error_flash').text.must_equal "This account is currently locked out and cannot be logged in to" page.body.must_include("This account is currently locked out") DB[:account_lockouts].update(:deadline=>Date.today - 3) login page.find('#notice_flash').text.must_equal 'You have been logged in' page.body.must_include("Logged In") end it "should change unlock key when changing login" do rodauth do enable :login, :lockout, :change_login require_login_confirmation? false change_login_requires_password? false max_invalid_logins 2 end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In!" : "Not Logged"} end login session1 = get_cookie('rack.session') remove_cookie('rack.session') visit '/login' fill_in 'Login', :with=>'foo@example.com' 3.times do fill_in 'Password', :with=>'012345678910' click_button 'Login' end page.find('#error_flash').text.must_equal "This account is currently locked out and cannot be logged in to" set_cookie('rack.session', session1) visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' key1 = DB[:account_lockouts].get(:key) key1.must_be_kind_of String click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" key2 = DB[:account_lockouts].get(:key) key2.must_be_kind_of String key1.wont_equal key2 end [true, false].each do |before| it "should clear unlock token when closing account, when loading lockout #{before ? "before" : "after"}" do rodauth do features = [:close_account, :lockout] features.reverse! if before enable(*features) max_invalid_logins 2 end roda do |r| r.get('b') do session[:account_id] = DB[:accounts].get(:id) 'b' end r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged")} end visit '/login' fill_in 'Login', :with=>'foo@example.com' 3.times do fill_in 'Password', :with=>'012345678910' click_button 'Login' end DB[:account_lockouts].count.must_equal 1 visit 'b' visit '/close-account' fill_in 'Password', :with=>'0123456789' click_button 'Close Account' DB[:account_lockouts].count.must_equal 0 end end it "should handle uniqueness errors raised when inserting unlock account token" do lockouts = [] rodauth do enable :lockout max_invalid_logins 2 after_account_lockout{lockouts << true} end roda do |r| def rodauth.raised_uniqueness_violation(*) super; true; end r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged")} end visit '/login' fill_in 'Login', :with=>'foo@example.com' fill_in 'Password', :with=>'012345678910' click_button 'Login' page.find('#error_flash').text.must_equal 'There was an error logging in' fill_in 'Password', :with=>'012345678910' click_button 'Login' lockouts.must_equal [true] page.find('#error_flash').text.must_equal "This account is currently locked out and cannot be logged in to" page.body.must_include("This account is currently locked out") click_button 'Request Account Unlock' page.find('#notice_flash').text.must_equal 'An email has been sent to you with a link to unlock your account' link = email_link(/(\/unlock-account\?key=.+)$/) visit link click_button 'Unlock Account' page.find('#notice_flash').text.must_equal 'Your account has been unlocked' page.body.must_include("Logged In") end it "should reraise uniqueness errors raised when inserting unlock account token if no token found" do lockouts = [] rodauth do enable :lockout max_invalid_logins 2 after_account_lockout{lockouts << true} end roda do |r| def rodauth.raised_uniqueness_violation(*, &_) ArgumentError.new; end r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged")} end visit '/login' fill_in 'Login', :with=>'foo@example.com' fill_in 'Password', :with=>'012345678910' click_button 'Login' page.find('#error_flash').text.must_equal 'There was an error logging in' fill_in 'Password', :with=>'012345678910' proc{click_button 'Login'}.must_raise ArgumentError end [:jwt, :json].each do |json| it "should support account lockouts via #{json}" do rodauth do enable :logout, :lockout max_invalid_logins 2 unlock_account_autologin? false unlock_account_email_body{unlock_account_email_link} end roda(json) do |r| r.rodauth [rodauth.logged_in? ? "Logged In" : "Not Logged"] end res = json_request('/unlock-account-request', :login=>'foo@example.com') res.must_equal [401, {'reason'=>'no_matching_login', 'error'=>"No matching login"}] res = json_login(:pass=>'1', :no_check=>true) res.must_equal [401, {'reason'=>"invalid_password",'error'=>"There was an error logging in", "field-error"=>["password", "invalid password"]}] json_login json_logout 2.times do res = json_login(:pass=>'1', :no_check=>true) res.must_equal [401, {'reason'=>"invalid_password",'error'=>"There was an error logging in", "field-error"=>["password", "invalid password"]}] end 2.times do res = json_login(:pass=>'1', :no_check=>true) res.must_equal [403, {'reason'=>"account_locked_out", 'error'=>"This account is currently locked out and cannot be logged in to"}] end res = json_request('/unlock-account') res.must_equal [401, {'reason'=>'invalid_unlock_account_key', 'error'=>"There was an error unlocking your account: invalid or expired unlock account key"}] res = json_request('/unlock-account-request', :login=>'foo@example.com') res.must_equal [200, {'success'=>"An email has been sent to you with a link to unlock your account"}] link = email_link(/key=.+$/) res = json_request('/unlock-account', :key=>link[4...-1]) res.must_equal [401, {'reason'=>'invalid_unlock_account_key', 'error'=>"There was an error unlocking your account: invalid or expired unlock account key"}] res = json_request('/unlock-account', :key=>link[4..-1]) res.must_equal [200, {'success'=>"Your account has been unlocked"}] res = json_request.must_equal [200, ['Not Logged']] json_login end end it "should support account locks, unlocks, and unlock requests using internal requests" do rodauth do enable :lockout, :logout, :internal_request account_lockouts_email_last_sent_column nil domain 'example.com' internal_request_configuration do csrf_tag { |*| fail "must not rely on Roda session" } end end roda do |r| r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged")} end proc do app.rodauth.lock_account(:account_login=>'foo3@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.unlock_account_request(:account_login=>'foo3@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.unlock_account(:account_login=>'foo3@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.unlock_account_request(:login=>'foo@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.unlock_account_request(:account_login=>'foo@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.unlock_account(:account_login=>'foo@example.com') end.must_raise Rodauth::InternalRequestError app.rodauth.lock_account(:account_login=>'foo@example.com').must_be_nil # Check idempotent app.rodauth.lock_account(:account_login=>'foo@example.com').must_be_nil login page.find('#error_flash').text.must_equal "This account is currently locked out and cannot be logged in to" app.rodauth.unlock_account_request(:login=>'foo@example.com').must_be_nil link = email_link(/(\/unlock-account\?key=.+)$/) app.rodauth.unlock_account_request(:account_login=>'foo@example.com').must_be_nil link2 = email_link(/(\/unlock-account\?key=.+)$/) link2.must_equal link visit link click_button 'Unlock Account' page.find('#notice_flash').text.must_equal 'Your account has been unlocked' page.body.must_include("Logged In") logout app.rodauth.lock_account(:account_login=>'foo@example.com').must_be_nil login page.find('#error_flash').text.must_equal "This account is currently locked out and cannot be logged in to" app.rodauth.unlock_account(:account_login=>'foo@example.com').must_be_nil login page.body.must_include 'Logged In' app.rodauth.lock_account(:account_login=>'foo@example.com').must_be_nil proc do app.rodauth.login(login: 'foo@example.com', password: "0123456789") end.must_raise Rodauth::InternalRequestError app.rodauth.unlock_account_request(:account_login=>'foo@example.com').must_be_nil link3 = email_link(/(\/unlock-account\?key=.+)$/) link3.wont_equal link2 key = link3.split('=').last proc do app.rodauth.unlock_account(:unlock_account_key=>key[0...-1]) end.must_raise Rodauth::InternalRequestError app.rodauth.unlock_account(:unlock_account_key=>key).must_be_nil login page.body.must_include 'Logged In' end end jeremyevans-rodauth-b53f402/spec/login_spec.rb000066400000000000000000000531431515725514200214530ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth login feature' do it "should handle logins and logouts" do login_column = :f rodauth do enable :login, :logout login_column{login_column} end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end visit '/login' page.title.must_equal 'Login' page.all('[type=text]').first.value.must_equal '' page.find_by_id('password')[:autocomplete].must_equal 'current-password' login_column = :email login(:login=>'foo@example2.com', :visit=>false) page.find('#error_flash').text.must_equal 'There was an error logging in' page.html.must_include("no matching login") page.all('[type=email]').first.value.must_equal 'foo@example2.com' login(:pass=>'012345678', :visit=>false) page.find('#error_flash').text.must_equal 'There was an error logging in' page.html.must_include("invalid password") fill_in 'Password', :with=>'0123456789' click_button 'Login' page.current_path.must_equal '/' page.find('#notice_flash').text.must_equal 'You have been logged in' page.html.must_include("Logged In") visit '/logout' page.title.must_equal 'Logout' click_button 'Logout' page.find('#notice_flash').text.must_equal 'You have been logged out' page.current_path.must_equal '/login' end it "should handle multi phase login (email first, then password)" do rodauth do enable :login, :logout use_multi_phase_login? true input_field_label_suffix ' (Required)' input_field_error_class ' bad-input' input_field_error_message_class 'err-msg' mark_input_fields_as_required? true field_attributes do |field| if field == 'login' 'custom_field="custom_value"' else super(field) end end field_error_attributes do |field| if field == 'login' 'custom_error_field="custom_error_value"' else super(field) end end formatted_field_error do |field, error| if field == 'login' super(field, error) else "1#{error}2" end end end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end visit '/login' page.title.must_equal 'Login' page.find('[custom_field=custom_value]').value.must_equal '' page.all('[custom_error_field=custom_error_value]').must_be_empty page.all('input[type=password]').must_be_empty fill_in 'Login (Required)', :with=>'foo2@example.com' click_button 'Login' page.find('#error_flash').text.must_equal 'There was an error logging in' page.find('[custom_field=custom_value]').value.must_equal 'foo2@example.com' page.find('[custom_error_field=custom_error_value]').value.must_equal 'foo2@example.com' page.find('[type=email]').value.must_equal 'foo2@example.com' page.find('.bad-input').value.must_equal 'foo2@example.com' page.find('.err-msg').text.must_equal 'no matching login' page.all('input[type=password]').must_be_empty fill_in 'Login (Required)', :with=>'foo@example.com' click_button 'Login' page.find('#notice_flash').text.must_equal 'Login recognized, please enter your password' page.all('[custom_field=custom_value]').must_be_empty page.all('[custom_error_field=custom_error_value]').must_be_empty page.all('[aria-invalid=true]').must_be_empty page.all('[aria-describedby]').must_be_empty page.find('[required=required]').value.to_s.must_equal '' page.all('input[type=text]').must_be_empty fill_in 'Password (Required)', :with=>'012345678' click_button 'Login' page.find('#error_flash').text.must_equal 'There was an error logging in' page.find('[aria-invalid=true]').value.to_s.must_equal '' page.find('[aria-describedby=password_error_message]').value.to_s.must_equal '' page.all('[custom_error_field=custom_error_value]').must_be_empty page.find('.err-msg2').text.must_equal '1invalid password2' page.all('input[type=text]').must_be_empty fill_in 'Password (Required)', :with=>'0123456789' click_button 'Login' page.current_path.must_equal '/' page.find('#notice_flash').text.must_equal 'You have been logged in' page.html.must_include("Logged In") visit '/logout' page.title.must_equal 'Logout' click_button 'Logout' page.find('#notice_flash').text.must_equal 'You have been logged out' page.current_path.must_equal '/login' end it "should allow returning to requested location when login was required" do rodauth do enable :login login_return_to_requested_location? true login_redirect '/' end roda do |r| r.rodauth r.get('page') do rodauth.require_login view :content=>"Passed Login Required: #{r.params['foo']}" end end visit '/page?foo=bar' login(:visit=>false) page.html.must_include 'Passed Login Required: bar' end it "should not return to requested location if a NON-GET request is used" do rodauth do enable :login login_return_to_requested_location? true login_redirect '/' end roda do |r| r.rodauth r.is('page') do rodauth.require_login if r.post? view :content=>"
#{rodauth.csrf_tag}
" end r.root do "default" end end visit '/page?foo=bar' click_button 'Submit' login(:visit=>false) page.html.must_equal 'default' end it "should allow returning to custom location" do rodauth do enable :login login_return_to_requested_location? true login_return_to_requested_location_path do "#{request.path}?foo=bar" end login_redirect '/' end roda do |r| r.rodauth r.get('page') do rodauth.require_login view :content=>"Passed Login Required: #{r.params['foo']}" end end visit '/page' login(:visit=>false) page.html.must_include 'Passed Login Required: bar' end it "should not return to requested path size if it is too long" do path = "/a"*1024 rodauth do enable :login, :logout login_return_to_requested_location? true login_return_to_requested_location_path do path end login_redirect '/' end roda do |r| r.rodauth rodauth.require_login "" end visit '/page' login(:visit=>false) page.current_path.must_equal path path = "/a"*4096 logout visit '/page' login(:visit=>false) page.current_path.must_equal "/" end it "should not allow login to unverified account" do rodauth do enable :login skip_status_checks? false end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end DB[:accounts].update(:status_id=>1) login page.find('#error_flash').text.must_equal 'There was an error logging in' page.html.must_include("unverified account, please verify account before logging in") end it "should handle overriding login action" do rodauth do enable :login end roda do |r| r.post 'login' do if r.params['login'] == 'apple' && r.params['password'] == 'banana' session['user_id'] = 'pear' r.redirect '/' end r.redirect '/login' end r.rodauth next unless session['user_id'] == 'pear' r.root{"Logged In"} end login(:login=>'appl', :pass=>'banana') page.html.wont_match(/Logged In/) login(:login=>'apple', :pass=>'banan', :visit=>false) page.html.wont_match(/Logged In/) login(:login=>'apple', :pass=>'banana', :visit=>false) page.current_path.must_equal '/' page.html.must_include("Logged In") end it "should handle overriding some login attributes" do rodauth do enable :login account_from_login do |login| DB[:accounts].first if login == 'apple' end password_match? do |password| password == 'banana' end update_session do session['user_id'] = 'pear' end no_matching_login_message "no user" invalid_password_message "bad password" end roda do |r| r.rodauth next unless session['user_id'] == 'pear' r.root{"Logged In"} end login(:login=>'appl', :pass=>'banana') page.html.must_include("no user") login(:login=>'apple', :pass=>'banan', :visit=>false) page.html.must_include("bad password") fill_in 'Password', :with=>'banana' click_button 'Login' page.current_path.must_equal '/' page.html.must_include("Logged In") end it "should handle a prefix and some other login options" do rodauth do enable :login, :logout prefix '/auth' session_key 'login_email' account_from_session{DB[:accounts].first(:email=>session_value)} account_session_value{account[:email]} login_param{param('lp')} login_additional_form_tags "" password_param 'p' login_redirect{"/foo/#{account[:email]}"} logout_redirect '/auth/lin' login_route 'lin' logout_route 'lout' end no_freeze! roda do |r| r.on 'auth' do r.rodauth end r.get('restricted'){rodauth.require_login} next unless session['login_email'] =~ /example/ r.get('foo', :email){|e| "Logged In: #{e}"} end app.plugin :render, :views=>'spec/views', :engine=>'str' visit '/auth/lin?lp=l' login(:login=>'foo@example2.com', :visit=>false) page.html.must_include("no matching login") login(:pass=>'012345678', :visit=>false) page.html.must_include("invalid password") login(:visit=>false) page.current_path.must_equal '/foo/foo@example.com' page.html.must_include("Logged In: foo@example.com") visit '/auth/lout' click_button 'Logout' page.current_path.must_equal '/auth/lin' visit '/restricted' page.current_path.must_equal '/auth/lin' end it "should use correct redirect paths when using prefix" do rodauth do enable :login, :logout prefix '/auth' end roda do |r| r.on 'auth' do r.rodauth rodauth.require_login end rodauth.send("#{r.remaining_path[1..-1]}_redirect") end visit '/login' page.html.must_equal '/' visit '/logout' page.html.must_equal '/auth/login' visit '/require_login' page.html.must_equal '/auth/login' visit '/auth' page.current_path.must_equal '/auth/login' end it "should allow manually logging in retrieved account" do rodauth do enable :login end roda do |r| r.get 'login' do rodauth.account_from_login("foo@example.com") rodauth.login('foo') end next unless rodauth.logged_in? r.root{view :content=>"Logged in via #{rodauth.authenticated_by.join(" ")}"} end visit '/login' page.current_path.must_equal '/' page.html.must_include 'Logged in via foo' page.find('#notice_flash').text.must_equal 'You have been logged in' end it "should support #_login for backwards compatibility" do warning = nil rodauth do enable :login auth_class_eval { define_method(:warn) { |msg| warning = msg } } end roda do |r| r.get 'login' do rodauth.account_from_login("foo@example.com") rodauth.send(:_login, 'foo') end next unless rodauth.logged_in? r.root{view :content=>"Logged in via #{rodauth.authenticated_by.join(" ")}"} end visit '/login' page.current_path.must_equal '/' page.html.must_include 'Logged in via foo' page.find('#notice_flash').text.must_equal 'You have been logged in' warning.must_equal "Deprecated #_login method called, use #login instead." end unless ENV['RODAUTH_NO_ARGON2'] == '1' begin require 'argon2' rescue LoadError else [false, true].each do |ph| it "should support argon2 secret #{'with account_password_hash_column' if ph}" do secret = "secret" rodauth do enable :argon2, :login, :create_account, :logout argon2_secret { secret } account_password_hash_column :ph if ph end roda do |r| r.rodauth r.root { view :content=>"Logged in" } end visit '/create-account' fill_in 'Login', :with=>'bar@example.com' fill_in 'Confirm Login', :with=>'bar@example.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_on 'Create Account' page.find('#notice_flash').text.must_equal "Your account has been created" logout login(:login=>'bar@example.com') page.find('#notice_flash').text.must_equal 'You have been logged in' logout secret = "secret2" login(:login=>'bar@example.com') page.find('#error_flash').text.must_equal 'There was an error logging in' end end describe "when a custom parallelism cost is specified for the argon2 password hash cost" do [true, false].each do |use_secret| it "should be able to login #{use_secret ? 'with' : 'without'} argon2 secret" do rodauth do enable :argon2, :login, :create_account, :logout # Custom p_cost of 8 argon2_secret 'secret' if use_secret password_hash_cost({ t_cost: 2, m_cost: 16, p_cost: 8 }) end roda do |r| r.rodauth r.root { view :content=>"Logged in" } end # We verify that we can create an account with custom cost configuration login = 'baz@example.com' pass = 'Abc123*' visit '/create-account' fill_in 'Login', with: login fill_in 'Confirm Login', with: login fill_in 'Password', with: pass fill_in 'Confirm Password', with: pass click_on 'Create Account' page.find('#notice_flash').text.must_equal "Your account has been created" logout # We verify that we can login with the custom config login(login: login, pass: pass) page.find('#notice_flash').text.must_equal 'You have been logged in' logout # We verify that subsequent logins keep working as well login(login: login, pass: pass) page.find('#notice_flash').text.must_equal 'You have been logged in' end describe 'and existing argon2 passwords exists with custom hash costs' do it 'should be able to login even if the hash cost changes' do custom_cost = { t_cost: 2, m_cost: 16, p_cost: 8 } rodauth do enable :argon2, :login, :create_account, :logout # Custom p_cost of 8 argon2_secret 'secret' password_hash_cost { custom_cost } end roda do |r| r.rodauth r.root { view :content=>"Logged in" } end # We verify that we can create an account with custom cost configuration login = 'baz@example.com' pass = 'Abc123*' visit '/create-account' fill_in 'Login', with: login fill_in 'Confirm Login', with: login fill_in 'Password', with: pass fill_in 'Confirm Password', with: pass click_on 'Create Account' page.find('#notice_flash').text.must_equal "Your account has been created" logout # We verify that we can login with the custom config login(login: login, pass: pass) page.find('#notice_flash').text.must_equal 'You have been logged in' logout # change hash cost and try to login again custom_cost = { t_cost: 1, m_cost: 5, p_cost: 1 } login(login: login, pass: pass) page.find('#notice_flash').text.must_equal 'You have been logged in' logout # create new user with new hash cost and test login with both users login_2 = 'dogs@example.com' pass_2 = 'woofWoof123*' visit '/create-account' fill_in 'Login', with: login_2 fill_in 'Confirm Login', with: login_2 fill_in 'Password', with: pass_2 fill_in 'Confirm Password', with: pass_2 click_on 'Create Account' page.find('#notice_flash').text.must_equal "Your account has been created" # First user login(login: login, pass: pass) page.find('#notice_flash').text.must_equal 'You have been logged in' logout # Second user login(login: login_2, pass: pass_2) page.find('#notice_flash').text.must_equal 'You have been logged in' logout # bring custom cost to previous values and test both users custom_cost = { t_cost: 2, m_cost: 16, p_cost: 8 } # First user login(login: login, pass: pass) page.find('#notice_flash').text.must_equal 'You have been logged in' logout # Second user login(login: login_2, pass: pass_2) page.find('#notice_flash').text.must_equal 'You have been logged in' logout end end end end end end it "should login and logout via jwt" do rodauth do enable :login, :logout json_response_custom_error_status? false jwt_secret{proc{super()}.must_raise Rodauth::ConfigurationError; "1"} end roda(:jwt) do |r| r.rodauth response[CONTENT_TYPE_KEY] = 'application/json' rodauth.logged_in? ? '1' : '2' end json_request.must_equal [200, 2] res = json_request("/login", :login=>'foo@example2.com', :password=>'0123456789') res.must_equal [400, {'reason'=>"no_matching_login",'error'=>"There was an error logging in", "field-error"=>["login", "no matching login"]}] res = json_request("/login", :login=>'foo@example.com', :password=>'012345678') res.must_equal [400, {'reason'=>"invalid_password",'error'=>"There was an error logging in", "field-error"=>["password", "invalid password"]}] json_request("/login", :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] json_request.must_equal [200, 1] json_request("/logout").must_equal [200, {"success"=>'You have been logged out'}] json_request.must_equal [200, 2] end [:jwt, :json].each do |json| it "should login and logout via #{json} with custom error statuses" do rodauth do enable :login, :logout end roda(json) do |r| r.rodauth response[CONTENT_TYPE_KEY] = 'application/json' r.post('foo') do rodauth.require_login '3' end rodauth.logged_in? ? '1' : '2' end json_request.must_equal [200, 2] res = json_request("/foo") res.must_equal [401, {"reason"=>"login_required", "error"=>"Please login to continue"}] res = json_request("/login", :login=>'foo@example2.com', :password=>'0123456789') res.must_equal [401, {'reason'=>"no_matching_login",'error'=>"There was an error logging in", "field-error"=>["login", "no matching login"]}] res = json_request("/login", :login=>'foo@example.com', :password=>'012345678') res.must_equal [401, {'reason'=>"invalid_password",'error'=>"There was an error logging in", "field-error"=>["password", "invalid password"]}] json_request("/login", :login=>'foo@example.com', :password=>'0123456789').must_equal [200, {"success"=>'You have been logged in'}] json_request.must_equal [200, 1] res = json_request("/foo").must_equal [200, 3] json_request("/logout").must_equal [200, {"success"=>'You have been logged out'}] json_request.must_equal [200, 2] end end it "should allow checking login and password using internal requests" do rodauth do enable :login, :internal_request end roda do |r| end app.rodauth.valid_login_and_password?(:login=>'foo@example.com', :password=>'0123456789').must_equal true app.rodauth.valid_login_and_password?(:login=>'foo@example.com', :password=>'012345678').must_equal false app.rodauth.valid_login_and_password?(:login=>'foo@example2.com', :password=>'0123456789').must_equal false app.rodauth.valid_login_and_password?(:account_login=>'foo@example.com', :password=>'0123456789').must_equal true app.rodauth.valid_login_and_password?(:account_login=>'foo@example.com', :password=>'012345678').must_equal false proc do app.rodauth.valid_login_and_password?(:account_login=>'foo@example2.com', :password=>'0123456789') end.must_raise Rodauth::InternalRequestError app.rodauth.login(:account_login=>'foo@example.com', :password=>'0123456789').must_equal DB[:accounts].get(:id) proc do app.rodauth.login(:login=>'foo@example.com', :password=>'012345678') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.login(:login=>'foo@example2.com', :password=>'0123456789') end.must_raise Rodauth::InternalRequestError app.rodauth.login(:account_login=>'foo@example.com', :password=>'0123456789').must_equal DB[:accounts].get(:id) proc do app.rodauth.login(:account_login=>'foo@example.com', :password=>'012345678') end.must_raise Rodauth::InternalRequestError end end jeremyevans-rodauth-b53f402/spec/migrate/000077500000000000000000000000001515725514200204265ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/spec/migrate/001_tables.rb000066400000000000000000000244371515725514200226170ustar00rootroot00000000000000Sequel.migration do up do primary_key_type = ENV['RODAUTH_SPEC_UUID'] && database_type == :postgres ? :uuid : :bigint extension :date_arithmetic # Used by the account verification and close account features create_table(:account_statuses) do Integer :id, :primary_key=>true String :name, :null=>false, :unique=>true end from(:account_statuses).import([:id, :name], [[1, 'Unverified'], [2, 'Verified'], [3, 'Closed']]) db = self create_table(:accounts) do if primary_key_type == :uuid uuid :id, :primary_key=>true, :default=>Sequel.function(:gen_random_uuid) else primary_key :id, :type=>:Bignum end foreign_key :status_id, :account_statuses, :null=>false, :default=>1 if db.database_type == :postgres citext :email, :null=>false constraint :valid_email, :email=>/^[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+$/ else String :email, :null=>false end if db.supports_partial_indexes? index :email, :unique=>true, :where=>{:status_id=>[1, 2]} else index :email, :unique=>true end end deadline_opts = proc do |days| if database_type == :mysql {:null=>false} else {:null=>false, :default=>Sequel.date_add(Sequel::CURRENT_TIMESTAMP, :days=>days)} end end # Used by the audit logging feature json_type = case database_type when :postgres :jsonb when :sqlite sqlite_version >= 34500 ? :jsonb : :json when :mysql :json else String end create_table(:account_authentication_audit_logs) do if primary_key_type == :uuid uuid :id, :primary_key=>true, :default=>Sequel.function(:gen_random_uuid) else primary_key :id, :type=>:Bignum end foreign_key :account_id, :accounts, :null=>false, :type=>primary_key_type DateTime :at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP String :message, :null=>false column :metadata, json_type index [:account_id, :at], :name=>:audit_account_at_idx index :at, :name=>:audit_at_idx end # Used by the password reset feature create_table(:account_password_reset_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false DateTime :deadline, deadline_opts[1] DateTime :email_last_sent, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end # Used by the jwt refresh feature create_table(:account_jwt_refresh_keys) do if primary_key_type == :uuid uuid :id, :primary_key=>true, :default=>Sequel.function(:gen_random_uuid) else primary_key :id, :type=>:Bignum end foreign_key :account_id, :accounts, :null=>false, :type=>primary_key_type String :key, :null=>false DateTime :deadline, deadline_opts[1] index :account_id, :name=>:account_jwt_rk_account_id_idx end # Used by the account verification feature create_table(:account_verification_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false DateTime :requested_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP DateTime :email_last_sent, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end # Used by the verify login change feature create_table(:account_login_change_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false String :login, :null=>false DateTime :deadline, deadline_opts[1] end # Used by the remember me feature create_table(:account_remember_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false DateTime :deadline, deadline_opts[14] end # Used by the lockout feature create_table(:account_login_failures) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type Integer :number, :null=>false, :default=>1 end create_table(:account_lockouts) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false DateTime :deadline, deadline_opts[1] DateTime :email_last_sent end # Used by the email auth feature create_table(:account_email_auth_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false DateTime :deadline, deadline_opts[1] DateTime :email_last_sent, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end # Used by the password expiration feature create_table(:account_password_change_times) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type DateTime :changed_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end # Used by the account expiration feature create_table(:account_activity_times) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type DateTime :last_activity_at, :null=>false DateTime :last_login_at, :null=>false DateTime :expired_at end # Used by the single session feature create_table(:account_session_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false end # Used by the active sessions feature create_table(:account_active_session_keys) do foreign_key :account_id, :accounts, :type=>primary_key_type String :session_id Time :created_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP Time :last_use, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP primary_key [:account_id, :session_id] end # Used by the webauthn feature create_table(:account_webauthn_user_ids) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :webauthn_id, :null=>false end create_table(:account_webauthn_keys) do foreign_key :account_id, :accounts, :type=>primary_key_type String :webauthn_id String :public_key, :null=>false Integer :sign_count, :null=>false Time :last_use, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP primary_key [:account_id, :webauthn_id] end # Used by the otp feature create_table(:account_otp_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false Integer :num_failures, :null=>false, :default=>0 Time :last_use, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end # Used by the otp_unlock feature create_table(:account_otp_unlocks) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type Integer :num_successes, :null=>false, :default=>1 Time :next_auth_attempt_after, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end # Used by the recovery codes feature create_table(:account_recovery_codes) do foreign_key :id, :accounts, :type=>primary_key_type String :code primary_key [:id, :code] end # Used by the sms codes feature create_table(:account_sms_codes) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :phone_number, :null=>false Integer :num_failures String :code DateTime :code_issued_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end case database_type when :postgres user = get(Sequel.lit('current_user')) + '_password' run "GRANT REFERENCES ON accounts TO #{user}" when :mysql, :mssql user = if database_type == :mysql get(Sequel.lit('current_user')).sub(/_password@/, '@') else get(Sequel.function(:DB_NAME)) end run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_statuses TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON accounts TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_authentication_audit_logs TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_password_reset_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_jwt_refresh_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_verification_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_login_change_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_remember_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_login_failures TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_email_auth_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_lockouts TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_password_change_times TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_activity_times TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_session_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_active_session_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_webauthn_user_ids TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_webauthn_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_otp_keys TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_otp_unlocks TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_recovery_codes TO #{user}" run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_sms_codes TO #{user}" end end down do drop_table(:account_sms_codes, :account_recovery_codes, :account_otp_unlocks, :account_otp_keys, :account_webauthn_keys, :account_webauthn_user_ids, :account_active_session_keys, :account_session_keys, :account_activity_times, :account_password_change_times, :account_email_auth_keys, :account_lockouts, :account_login_failures, :account_remember_keys, :account_login_change_keys, :account_verification_keys, :account_jwt_refresh_keys, :account_password_reset_keys, :account_authentication_audit_logs, :accounts, :account_statuses) end end jeremyevans-rodauth-b53f402/spec/migrate/002_account_password_hash_column.rb000066400000000000000000000003371515725514200272750ustar00rootroot00000000000000Sequel.migration do up do # Only for testing of account_password_hash_column, not recommended for new # applications add_column :accounts, :ph, String end down do drop_column :accounts, :ph end end jeremyevans-rodauth-b53f402/spec/migrate_ci/000077500000000000000000000000001515725514200211015ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/spec/migrate_ci/001_tables.rb000066400000000000000000000164011515725514200232620ustar00rootroot00000000000000require 'rodauth/migrations' Sequel.migration do up do primary_key_type = ENV['RODAUTH_SPEC_UUID'] && database_type == :postgres ? :uuid : :bigint extension :date_arithmetic create_table(:account_statuses) do Integer :id, :primary_key=>true String :name, :null=>false, :unique=>true end from(:account_statuses).import([:id, :name], [[1, 'Unverified'], [2, 'Verified'], [3, 'Closed']]) db = self create_table(:accounts) do if primary_key_type == :uuid uuid :id, :primary_key=>true, :default=>Sequel.function(:gen_random_uuid) else primary_key :id, :type=>:Bignum end foreign_key :status_id, :account_statuses, :null=>false, :default=>1 if db.database_type == :postgres citext :email, :null=>false constraint :valid_email, :email=>/^[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+$/ else String :email, :null=>false end if db.supports_partial_indexes? index :email, :unique=>true, :where=>{:status_id=>[1, 2]} else index :email, :unique=>true end String :ph end create_table(:account_password_hashes) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :password_hash, :null=>false end Rodauth.create_database_authentication_functions(self, argon2: ENV['RODAUTH_NO_ARGON2'] != '1') deadline_opts = proc do |days| if database_type == :mysql {:null=>false} else {:null=>false, :default=>Sequel.date_add(Sequel::CURRENT_TIMESTAMP, :days=>days)} end end json_type = case database_type when :postgres :jsonb when :sqlite sqlite_version >= 34500 ? :jsonb : :json when :mysql :json else String end create_table(:account_authentication_audit_logs) do if primary_key_type == :uuid uuid :id, :primary_key=>true, :default=>Sequel.function(:gen_random_uuid) else primary_key :id, :type=>:Bignum end foreign_key :account_id, :accounts, :null=>false, :type=>primary_key_type DateTime :at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP String :message, :null=>false column :metadata, json_type index [:account_id, :at], :name=>:audit_account_at_idx index :at, :name=>:audit_at_idx end create_table(:account_password_reset_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false DateTime :deadline, deadline_opts[1] DateTime :email_last_sent, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end create_table(:account_jwt_refresh_keys) do if primary_key_type == :uuid uuid :id, :primary_key=>true, :default=>Sequel.function(:gen_random_uuid) else primary_key :id, :type=>:Bignum end foreign_key :account_id, :accounts, :null=>false, :type=>primary_key_type String :key, :null=>false DateTime :deadline, deadline_opts[1] index :account_id, :name=>:account_jwt_rk_account_id_idx end create_table(:account_verification_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false DateTime :requested_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP DateTime :email_last_sent, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end create_table(:account_login_change_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false String :login, :null=>false DateTime :deadline, deadline_opts[1] end create_table(:account_remember_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false DateTime :deadline, deadline_opts[14] end create_table(:account_email_auth_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false DateTime :deadline, deadline_opts[1] DateTime :email_last_sent, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end create_table(:account_login_failures) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type Integer :number, :null=>false, :default=>1 end create_table(:account_lockouts) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false DateTime :deadline, deadline_opts[1] DateTime :email_last_sent end create_table(:account_password_change_times) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type DateTime :changed_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end create_table(:account_activity_times) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type DateTime :last_activity_at, :null=>false DateTime :last_login_at, :null=>false DateTime :expired_at end create_table(:account_session_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false end create_table(:account_active_session_keys) do foreign_key :account_id, :accounts, :type=>primary_key_type String :session_id Time :created_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP Time :last_use, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP primary_key [:account_id, :session_id] end create_table(:account_webauthn_user_ids) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :webauthn_id, :null=>false end create_table(:account_webauthn_keys) do foreign_key :account_id, :accounts, :type=>primary_key_type String :webauthn_id String :public_key, :null=>false Integer :sign_count, :null=>false Time :last_use, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP primary_key [:account_id, :webauthn_id] end create_table(:account_otp_keys) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :key, :null=>false Integer :num_failures, :null=>false, :default=>0 Time :last_use, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end create_table(:account_otp_unlocks) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type Integer :num_successes, :null=>false, :default=>1 Time :next_auth_attempt_after, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end create_table(:account_recovery_codes) do foreign_key :id, :accounts, :type=>primary_key_type String :code primary_key [:id, :code] end create_table(:account_sms_codes) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :phone_number, :null=>false Integer :num_failures String :code DateTime :code_issued_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP end create_table(:account_previous_password_hashes) do primary_key :id, :type=>:Bignum foreign_key :account_id, :accounts, :type=>primary_key_type String :password_hash, :null=>false end Rodauth.create_database_previous_password_check_functions(self, argon2: ENV['RODAUTH_NO_ARGON2'] != '1') end end jeremyevans-rodauth-b53f402/spec/migrate_password/000077500000000000000000000000001515725514200223505ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/spec/migrate_password/001_tables.rb000066400000000000000000000075321515725514200245360ustar00rootroot00000000000000require 'rodauth/migrations' Sequel.migration do up do primary_key_type = ENV['RODAUTH_SPEC_UUID'] && database_type == :postgres ? :uuid : :bigint create_table(:account_password_hashes) do foreign_key :id, :accounts, :primary_key=>true, :type=>primary_key_type String :password_hash, :null=>false end Rodauth.create_database_authentication_functions(self, argon2: ENV['RODAUTH_NO_ARGON2'] != '1') case database_type when :postgres user = get(Sequel.lit('current_user')).sub(/_password\z/, '') run "REVOKE ALL ON account_password_hashes FROM public" run "REVOKE ALL ON FUNCTION rodauth_get_salt(#{primary_key_type}) FROM public" run "REVOKE ALL ON FUNCTION rodauth_valid_password_hash(#{primary_key_type}, text) FROM public" run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}" run "GRANT SELECT(id) ON account_password_hashes TO #{user}" run "GRANT EXECUTE ON FUNCTION rodauth_get_salt(#{primary_key_type}) TO #{user}" run "GRANT EXECUTE ON FUNCTION rodauth_valid_password_hash(#{primary_key_type}, text) TO #{user}" when :mysql user = get(Sequel.lit('current_user')).sub(/_password@/, '@') db_name = get(Sequel.function(:database)) run "GRANT EXECUTE ON #{db_name}.* TO #{user}" run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}" run "GRANT SELECT (id) ON account_password_hashes TO #{user}" when :mssql user = get(Sequel.function(:DB_NAME)) run "GRANT EXECUTE ON rodauth_get_salt TO #{user}" run "GRANT EXECUTE ON rodauth_valid_password_hash TO #{user}" run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}" run "GRANT SELECT ON account_password_hashes(id) TO #{user}" end # Used by the disallow_password_reuse feature create_table(:account_previous_password_hashes) do primary_key :id, :type=>:Bignum foreign_key :account_id, :accounts, :type=>primary_key_type String :password_hash, :null=>false end Rodauth.create_database_previous_password_check_functions(self, argon2: ENV['RODAUTH_NO_ARGON2'] != '1') case database_type when :postgres user = get(Sequel.lit('current_user')).sub(/_password\z/, '') run "REVOKE ALL ON account_previous_password_hashes FROM public" run "REVOKE ALL ON FUNCTION rodauth_get_previous_salt(int8) FROM public" run "REVOKE ALL ON FUNCTION rodauth_previous_password_hash_match(int8, text) FROM public" run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}" run "GRANT SELECT(id, account_id) ON account_previous_password_hashes TO #{user}" run "GRANT USAGE ON account_previous_password_hashes_id_seq TO #{user}" run "GRANT EXECUTE ON FUNCTION rodauth_get_previous_salt(int8) TO #{user}" run "GRANT EXECUTE ON FUNCTION rodauth_previous_password_hash_match(int8, text) TO #{user}" when :mysql user = get(Sequel.lit('current_user')).sub(/_password@/, '@') db_name = get(Sequel.function(:database)) run "GRANT EXECUTE ON #{db_name}.* TO #{user}" run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}" run "GRANT SELECT (id, account_id) ON account_previous_password_hashes TO #{user}" when :mssql user = get(Sequel.function(:DB_NAME)) run "GRANT EXECUTE ON rodauth_get_previous_salt TO #{user}" run "GRANT EXECUTE ON rodauth_previous_password_hash_match TO #{user}" run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}" run "GRANT SELECT ON account_previous_password_hashes(id, account_id) TO #{user}" end end down do Rodauth.drop_database_previous_password_check_functions(self) Rodauth.drop_database_authentication_functions(self) drop_table(:account_previous_password_hashes, :account_password_hashes) end end jeremyevans-rodauth-b53f402/spec/otp_lockout_email_spec.rb000066400000000000000000000105151515725514200240500ustar00rootroot00000000000000require_relative 'spec_helper' require 'rotp' describe 'Rodauth otp_lockout_email feature' do secret_length = (ROTP::Base32.respond_to?(:random_base32) ? ROTP::Base32.random_base32 : ROTP::Base32.random).length def reset_otp_last_use DB[:account_otp_keys].update(:last_use=>Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, :seconds=>600)) end def reset_otp_unlock_next_attempt_after DB[:account_otp_unlocks].update(:next_auth_attempt_after=>Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, :seconds=>1)) end it "should email when otp authentication is locked out, unlocked, or has a failed unlock attempt" do send_email = true rodauth do enable :login, :logout, :otp_lockout_email send_otp_locked_out_email?{send_email} send_otp_unlocked_email?{send_email} send_otp_unlock_failed_email?{send_email} hmac_secret '123' end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/otp-auth' unless rodauth.authenticated? view :content=>"With 2FA" else view :content=>"Without 2FA" end end login visit '/otp-setup' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' reset_otp_last_use logout login 6.times do page.title.must_equal 'Enter Authentication Code' fill_in 'Authentication Code', :with=>'foo' click_button 'Authenticate Using TOTP' end email = email_sent email.subject.must_equal "TOTP Authentication Locked Out" email.body.to_s.must_equal <'1' click_button 'Authenticate Using TOTP to Unlock' email = email_sent email.subject.must_equal "TOTP Authentication Unlocking Failed" email.body.to_s.must_equal <totp.now click_button 'Authenticate Using TOTP to Unlock' reset_otp_unlock_next_attempt_after visit page.current_path end fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP to Unlock' email = email_sent email.subject.must_equal "TOTP Authentication Unlocked" email.body.to_s.must_equal <'foo' click_button 'Authenticate Using TOTP' end reset_otp_unlock_next_attempt_after visit page.current_path fill_in 'Authentication Code', :with=>'1' click_button 'Authenticate Using TOTP to Unlock' reset_otp_unlock_next_attempt_after visit page.current_path 2.times do |i| fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP to Unlock' reset_otp_unlock_next_attempt_after visit page.current_path end fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP to Unlock' Mail::TestMailer.deliveries.must_be_empty end end jeremyevans-rodauth-b53f402/spec/otp_modify_email_spec.rb000066400000000000000000000030621515725514200236560ustar00rootroot00000000000000require_relative 'spec_helper' require 'rotp' describe 'Rodauth otp_lockout_email feature' do secret_length = (ROTP::Base32.respond_to?(:random_base32) ? ROTP::Base32.random_base32 : ROTP::Base32.random).length it "should email when otp authentication is locked out, unlocked, or has a failed unlock attempt" do rodauth do enable :login, :logout, :otp_modify_email hmac_secret '123' end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/otp-auth' unless rodauth.authenticated? view :content=>"With 2FA" else view :content=>"Without 2FA" end end login visit '/otp-setup' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' email = email_sent email.subject.must_equal "TOTP Authentication Setup" email.body.to_s.must_equal <'0123456789' click_button 'Disable TOTP Authentication' email = email_sent email.subject.must_equal "TOTP Authentication Disabled" email.body.to_s.must_equal <Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, :seconds=>600)) end def reset_otp_unlock_next_attempt_after DB[:account_otp_unlocks].update(:next_auth_attempt_after=>Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, :seconds=>1)) end it "should allow unlocking totp authentication" do rodauth do enable :login, :logout, :otp_unlock hmac_secret '123' otp_unlock_next_auth_attempt_refresh_label do super() + otp_unlock_refresh_tag end end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/otp-auth' unless rodauth.authenticated? view :content=>"With 2FA" else view :content=>"Without 2FA" end end visit '/otp-unlock' page.title.must_equal 'Login' login visit '/otp-unlock' page.title.must_equal 'Setup TOTP Authentication' login visit '/otp-setup' page.title.must_equal 'Setup TOTP Authentication' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With 2FA' reset_otp_last_use visit '/otp-unlock' page.find('#error_flash').text.must_equal 'TOTP authentication is not currently locked out' page.html.must_include 'With 2FA' logout login 6.times do page.title.must_equal 'Enter Authentication Code' fill_in 'Authentication Code', :with=>'foo' click_button 'Authenticate Using TOTP' end fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP to Unlock' reset_otp_unlock_next_attempt_after page.current_path.must_equal '/otp-unlock' visit '/multifactor-auth' page.current_path.must_equal '/otp-unlock' page.html.must_include "Consecutive successful authentications: 1" DB[:account_otp_unlocks].update(:next_auth_attempt_after=>Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, :seconds=>1000)) fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP to Unlock' page.find('#error_flash').text.must_equal 'Deadline past for unlocking TOTP authentication' page.html.must_include "Consecutive successful authentications: 0" fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP to Unlock' reset_otp_unlock_next_attempt_after visit page.current_path page.html.must_include "Consecutive successful authentications: 1" DB[:account_otp_unlocks].update(:next_auth_attempt_after=>Sequel.date_add(Sequel::CURRENT_TIMESTAMP, :seconds=>1000)) fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP to Unlock' page.find('#error_flash').text.must_equal 'TOTP unlock attempt not yet available' page.html.must_include "Consecutive successful authentications: 1" page.title.must_equal 'Must Wait to Unlock TOTP Authentication' reset_otp_unlock_next_attempt_after visit page.current_path fill_in 'Authentication Code', :with=>'1' click_button 'Authenticate Using TOTP to Unlock' page.find('#error_flash').text.must_equal 'TOTP invalid authentication' page.html.must_include "Consecutive successful authentications: 0" reset_otp_unlock_next_attempt_after visit page.current_path fill_in 'Authentication Code', :with=>'1' click_button 'Authenticate Using TOTP to Unlock' page.find('#error_flash').text.must_equal 'TOTP invalid authentication' reset_otp_unlock_next_attempt_after visit page.current_path 2.times do |i| page.title.must_equal 'Unlock TOTP Authentication' page.html.must_include "Consecutive successful authentications: #{i}" page.html.must_include 'Required consecutive successful authentications to unlock: 3' page.html.must_include "Deadline for next authentication: " fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP to Unlock' page.find('#notice_flash').text.must_equal 'TOTP successful authentication, more successful authentication needed to unlock' page.title.must_equal 'Must Wait to Unlock TOTP Authentication' page.html.must_include "Consecutive successful authentications: #{i+1}" page.html.must_include 'Required consecutive successful authentications to unlock: 3' page.html.must_include "Can attempt next authentication after: " page.html.must_include "Page will automatically refresh when authentication is possible." page.response_headers['refresh'].must_match(/\A1[012]\d\z/) page.html.must_match(//) reset_otp_unlock_next_attempt_after visit page.current_path end page.html.must_include 'Consecutive successful authentications: 2' page.html.must_include 'Required consecutive successful authentications to unlock: 3' page.html.must_include "Deadline for next authentication: " fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP to Unlock' page.find('#notice_flash').text.must_equal 'TOTP authentication unlocked' page.current_path.must_equal '/otp-auth' page.title.must_equal 'Enter Authentication Code' end [:jwt, :json_no_enable].each do |json| use_json = true it "should allow unlock otp via #{json}" do rodauth do enable :login, :logout, :otp_unlock, :json json_response_success_key 'success' use_json?{use_json} only_json?{use_json} set_error_reason { |reason| json_response['reason'] = reason } end roda(json) do |r| r.rodauth if rodauth.logged_in? if rodauth.two_factor_authentication_setup? if rodauth.authenticated? [1] else [2] end else [3] end else [4] end end json_request.must_equal [200, [4]] json_login json_request.must_equal [200, [3]] res = json_request('/otp-unlock') res.must_equal [403, {"reason"=>"two_factor_not_setup", "error"=>"This account has not been setup for multifactor authentication"}] secret = (ROTP::Base32.respond_to?(:random_base32) ? ROTP::Base32.random_base32 : ROTP::Base32.random).downcase totp = ROTP::TOTP.new(secret) res = json_request('/otp-setup', :password=>'0123456789', :otp=>totp.now, :otp_secret=>secret) res.must_equal [200, {'success'=>'TOTP authentication is now setup'}] reset_otp_last_use json_request.must_equal [200, [1]] json_logout json_login json_request.must_equal [200, [2]] res = json_request('/otp-unlock', :otp=>totp.now) res.must_equal [403, {"reason"=>"otp_not_locked_out", "error"=>"TOTP authentication is not currently locked out"}] 5.times do res = json_request('/otp-auth', :otp=>'adsf') res.must_equal [401, {'reason'=>"invalid_otp_auth_code",'error'=>'Error logging in via TOTP authentication', "field-error"=>["otp", 'Invalid authentication code']}] end res = json_request('/otp-auth', :otp=>'adsf') res.must_equal [403, {"reason"=>"otp_locked_out", "error"=>"TOTP authentication code use locked out due to numerous failures"}] range = (-15..15) res = json_request('/otp-unlock', :otp=>'adsf') range.must_include(Time.now.to_i + 900 - res[1].delete("next_attempt_after")) res.must_equal [403, {"reason"=>"otp_unlock_auth_failure", "error"=>"TOTP invalid authentication", "num_successes"=>0, "required_successes"=>3}] res = json_request('/otp-unlock', :otp=>totp.now) range.must_include(Time.now.to_i + 900 - res[1].delete("next_attempt_after")) res.must_equal [403, {"reason"=>"otp_unlock_not_yet_available", "error"=>"TOTP unlock attempt not yet available", "num_successes"=>0, "required_successes"=>3}] DB[:account_otp_unlocks].update(:next_auth_attempt_after=>Sequel.date_add(Sequel::CURRENT_TIMESTAMP, :seconds=>-1000)) res = json_request('/otp-unlock', :otp=>totp.now) res.must_equal [403, {"reason"=>"otp_unlock_deadline_passed", "error"=>"Deadline past for unlocking TOTP authentication"}] reset_otp_unlock_next_attempt_after if json == :json_no_enable use_json = false login visit '/otp-unlock' fill_in 'Authentication Code', :with=>'1' click_button 'Authenticate Using TOTP to Unlock' page.find('#error_flash').text.must_equal 'TOTP invalid authentication' reset_otp_unlock_next_attempt_after use_json = true end 2.times do |i| res = json_request('/otp-unlock', :otp=>totp.now) range.must_include(Time.now.to_i + 120 - res[1].delete("next_attempt_after")) range.must_include(Time.now.to_i + 1020 - res[1].delete("deadline")) res.must_equal [200, {"success"=>"TOTP successful authentication, more successful authentication needed to unlock", "num_successes"=>i+1, "required_successes"=>3}] reset_otp_unlock_next_attempt_after end res = json_request('/otp-unlock', :otp=>totp.now) res.must_equal [200, {"success"=>"TOTP authentication unlocked"}] end end end jeremyevans-rodauth-b53f402/spec/override-views/000077500000000000000000000000001515725514200217505ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/spec/override-views/button.str000066400000000000000000000002701515725514200240140ustar00rootroot00000000000000
jeremyevans-rodauth-b53f402/spec/override-views/password-field.str000066400000000000000000000004571515725514200254330ustar00rootroot00000000000000
#{rodauth.input_field_string(rodauth.password_param, 'password', :type => 'password', :autocomplete=>rodauth.password_field_autocomplete_value)}
jeremyevans-rodauth-b53f402/spec/password_complexity_spec.rb000066400000000000000000000122321515725514200244540ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth password complexity feature' do it "should do additional password complexity checks" do rodauth do enable :login, :change_password, :password_complexity change_password_requires_password? false password_dictionary_file 'spec/words' end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-password' bad_passwords = [ ["minimum 6 characters", %w"a1OX"], ["does not include uppercase letters, lowercase letters, and numbers", %w'sdflksdfl sdflks!fl Sdflksdfl dfl1sdfl DFL1SDFL DFL!SDFL'], ["includes common character sequence", %w"Aqwerty12 Aazerty12 HA123ha HA234ha HA345ha HA456ha HA567ha HA678ha HA789ha HA890ha"], ["contains too many of the same character in a row", %w"Helll0 Hellllll0"], ["is a word in a dictionary", %w"Password1 1Password1 1PaSSword1 1P@$5w0Rd1 2398|3@$+7809 2|!7+1e l4$7$124 N!88|e56"] ] bad_passwords.each do |message, passwords| passwords.each do |pass| fill_in 'New Password', :with=>pass fill_in 'Confirm Password', :with=>pass click_button 'Change Password' page.html.must_include("invalid password, does not meet requirements (#{message})") page.find('#error_flash').text.must_equal "There was an error changing your password" end end fill_in 'New Password', :with=>'footpassword' fill_in 'Confirm Password', :with=>'footpassword' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end it "should support default dictionary" do default_dictionary = '/usr/share/dict/words' skip("#{default_dictionary} not present") unless File.file?(default_dictionary) pass = File.read(default_dictionary).split.sort_by{|w| w.length}.last skip("#{default_dictionary} empty") unless pass pass = pass.downcase.gsub(/[^a-z]/, '') rodauth do enable :login, :change_password, :password_complexity change_password_requires_password? false end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-password' fill_in 'New Password', :with=>"135#{pass}135" fill_in 'Confirm Password', :with=>"135#{pass}135" click_button 'Change Password' page.html.must_include("invalid password") page.find('#error_flash').text.must_equal "There was an error changing your password" fill_in 'New Password', :with=>'footpassword' fill_in 'Confirm Password', :with=>'footpassword' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end it "should support no dictionary" do default_dictionary = '/usr/share/dict/words' skip("#{default_dictionary} not present") unless File.file?(default_dictionary) rodauth do enable :login, :change_password, :password_complexity change_password_requires_password? false password_dictionary_file false end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-password' fill_in 'New Password', :with=>"password123" fill_in 'Confirm Password', :with=>"password123" click_button 'Change Password' page.html.must_include("invalid password") page.find('#error_flash').text.must_equal "There was an error changing your password" fill_in 'New Password', :with=>'Password1' fill_in 'Confirm Password', :with=>'Password1' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end it "should support custom dictionary and customized options" do rodauth do enable :login, :change_password, :password_complexity change_password_requires_password? false password_invalid_pattern nil password_max_repeating_characters 0 password_dictionary File.read('spec/words').downcase.split end roda do |r| r.rodauth r.root{view :content=>""} end login page.current_path.must_equal '/' visit '/change-password' bad_passwords = [ ["minimum 6 characters", %w"a1OX"], ["does not include uppercase letters, lowercase letters, and numbers", %w'sdflksdfl sdflks!fl Sdflksdfl dfl1sdfl DFL1SDFL DFL!SDFL'], ["is a word in a dictionary", %w"Password1 1Password1 1PaSSword1 1P@$5w0Rd1 2398|3@$+7809 2|!7+1e N!88|e56"] ] bad_passwords.each do |message, passwords| passwords.each do |pass| fill_in 'New Password', :with=>pass fill_in 'Confirm Password', :with=>pass click_button 'Change Password' page.html.must_include("invalid password, does not meet requirements (#{message})") page.find('#error_flash').text.must_equal "There was an error changing your password" end end fill_in 'New Password', :with=>'foot.password' fill_in 'Confirm Password', :with=>'foot.password' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end end jeremyevans-rodauth-b53f402/spec/password_expiration_spec.rb000066400000000000000000000207211515725514200244430ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth password expiration feature' do it "should force password changes after x number of days" do rodauth do enable :login, :logout, :change_password, :reset_password, :password_expiration allow_password_change_after 1000 change_password_requires_password? false end roda do |r| r.rodauth rodauth.require_current_password if rodauth.logged_in? r.root{view :content=>""} end login(:pass=>'01234567') click_button 'Request Password Reset' link = email_link(/(\/reset-password\?key=.+)$/) visit link[0...-1] page.find('#error_flash').text.must_equal "There was an error resetting your password: invalid or expired password reset key" visit link page.current_path.must_equal '/reset-password' login page.current_path.must_equal '/' visit '/change-password' fill_in 'New Password', :with=>'banana' fill_in 'Confirm Password', :with=>'banana' click_button 'Change Password' page.current_path.must_equal '/' visit '/change-password' page.current_path.must_equal '/' page.find('#error_flash').text.must_equal "Your password cannot be changed yet" logout visit link page.current_path.must_equal '/' page.find('#error_flash').text.must_equal "Your password cannot be changed yet" DB[:account_password_change_times].update(:changed_at=>Time.now - 1100) visit link page.current_path.must_equal '/reset-password' login(:pass=>'banana') page.current_path.must_equal '/' visit '/change-password' page.current_path.must_equal '/change-password' logout DB[:account_password_change_times].update(:changed_at=>Time.now - 91*86400) login(:pass=>'banana') page.current_path.must_equal '/change-password' page.find('#error_flash').text.must_equal "Your password has expired and needs to be changed" visit '/foo' page.current_path.must_equal '/change-password' page.find('#error_flash').text.must_equal "Your password has expired and needs to be changed" fill_in 'New Password', :with=>'banana2' fill_in 'Confirm Password', :with=>'banana2' click_button 'Change Password' page.current_path.must_equal '/' visit '/change-password' page.current_path.must_equal '/' page.find('#error_flash').text.must_equal "Your password cannot be changed yet" logout visit link page.current_path.must_equal '/' page.find('#error_flash').text.must_equal "Your password cannot be changed yet" end it "should update password changed at when creating accounts" do rodauth do enable :login, :change_password, :password_expiration password_expiration_default true change_password_requires_password? false end roda do |r| r.rodauth rodauth.require_current_password r.root{view :content=>""} end login page.current_path.must_equal '/change-password' visit '/' page.current_path.must_equal '/change-password' fill_in 'New Password', :with=>'banana' fill_in 'Confirm Password', :with=>'banana' click_button 'Change Password' page.current_path.must_equal '/' end it "should update password changed at when creating accounts" do rodauth do enable :login, :create_account, :password_expiration allow_password_change_after 1000 account_password_hash_column :ph end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' fill_in 'Login', :with=>'foo2@example.com' fill_in 'Confirm Login', :with=>'foo2@example.com' fill_in 'Password', :with=>'apple2' fill_in 'Confirm Password', :with=>'apple2' click_button 'Create Account' visit '/change-password' page.current_path.must_equal '/' page.find('#error_flash').text.must_equal "Your password cannot be changed yet" end [true, false].each do |before| it "should remove password expiration data when closing accounts, when loading password_expiration #{before ? "before" : "after"}" do rodauth do features = [:create_account, :close_account, :password_expiration] features.reverse! if before enable :login, *features close_account_requires_password? false create_account_autologin? true end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' fill_in 'Login', :with=>'foo2@example.com' fill_in 'Confirm Login', :with=>'foo2@example.com' fill_in 'Password', :with=>'apple2' fill_in 'Confirm Password', :with=>'apple2' click_button 'Create Account' DB[:account_password_change_times].count.must_equal 1 visit '/close-account' click_button 'Close Account' DB[:account_password_change_times].count.must_equal 0 end end it "should handle the case where the password is expired while the user has logged in" do rodauth do enable :login, :change_password, :password_expiration password_expiration_default true allow_password_change_after(-1000) change_password_requires_password? false require_password_change_after 3600 end roda do |r| r.rodauth rodauth.require_current_password r.get("expire", :d){|d| session[rodauth.password_changed_at_session_key] = Time.now.to_i - d.to_i; r.redirect '/'} r.root{view :content=>""} end login page.current_path.must_equal '/change-password' visit '/' page.current_path.must_equal '/change-password' fill_in 'New Password', :with=>'banana' fill_in 'Confirm Password', :with=>'banana' click_button 'Change Password' page.current_path.must_equal '/' visit "/expire/90" page.current_path.must_equal '/' visit "/expire/7200" page.current_path.must_equal '/change-password' end [:jwt, :json].each do |json| it "should force password changes via #{json}" do rodauth do enable :login, :logout, :change_password, :reset_password, :password_expiration allow_password_change_after 1000 change_password_requires_password? false reset_password_email_body{reset_password_email_link} end roda(json) do |r| r.rodauth rodauth.require_current_password if rodauth.authenticated? [1] else [2] end end json_request.must_equal [200, [2]] res = json_request('/reset-password-request', :login=>'foo@example.com') res.must_equal [200, {"success"=>"An email has been sent to you with a link to reset the password for your account"}] link = email_link(/key=.+$/) json_login res = json_request('/change-password', :password=>'0123456789', "new-password"=>'0123456', "password-confirm"=>'0123456') res.must_equal [200, {'success'=>"Your password has been changed"}] json_request.must_equal [200, [1]] res = json_request('/change-password', :password=>'0123456', "new-password"=>'01234567', "password-confirm"=>'01234567') res.must_equal [400, {'error'=>"Your password cannot be changed yet"}] json_logout res = json_request('/reset-password', :key=>link[4..-1], :password=>'01234567', "password-confirm"=>'01234567') res.must_equal [400, {'error'=>"Your password cannot be changed yet"}] DB[:account_password_change_times].update(:changed_at=>Time.now - 1100) res = json_request('/reset-password', :key=>link[4..-1], :password=>'01234567', "password-confirm"=>'01234567') res.must_equal [200, {"success"=>"Your password has been reset"}] DB[:account_password_change_times].update(:changed_at=>Time.now - 1100) json_login(:pass=>'01234567') res = json_request('/change-password', :password=>'01234567', "new-password"=>'012345678', "password-confirm"=>'012345678') res.must_equal [200, {'success'=>"Your password has been changed"}] DB[:account_password_change_times].update(:changed_at=>Time.now - 91*86400) json_logout json_request.must_equal [200, [2]] res = json_login(:pass=>'012345678', :no_check=>true) res.must_equal [400, {'error'=>"Your password has expired and needs to be changed"}] json_request.must_equal [400, {'error'=>"Your password has expired and needs to be changed"}] res = json_request('/change-password', :password=>'012345678', "new-password"=>'012345678a', "password-confirm"=>'012345678a') res.must_equal [200, {'success'=>"Your password has been changed"}] json_request.must_equal [200, [1]] end end end jeremyevans-rodauth-b53f402/spec/password_grace_period_spec.rb000066400000000000000000000111541515725514200247040ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth password grace period feature' do it "should not ask for password again if password was recently entered" do grace = 300 rodauth do enable :login, :change_login, :password_grace_period password_grace_period{grace} require_login_confirmation? false end roda do |r| r.rodauth r.get("reset"){session.delete(rodauth.last_password_entry_session_key); ""} r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login page.body.must_include "Logged In" visit '/change-login' fill_in 'Login', :with=>'foo2@example.com' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" grace = -1 visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' fill_in 'Password', :with=>'0123456789' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" grace = 300 visit '/change-login' grace = -1 fill_in 'Login', :with=>'foo4@example.com' click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.html.must_include("invalid password") fill_in 'Password', :with=>'0123456789' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" visit '/reset' visit '/change-login' fill_in 'Login', :with=>'foo5@example.com' fill_in 'Password', :with=>'0123456789' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" end [true, false].each do |before| it "should not ask for password again directly after creating an account, when loading password_grace_period #{before ? "before" : "after"}" do rodauth do features = [:create_account, :password_grace_period] features.reverse! if before enable :change_login, *features require_login_confirmation? false end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo2@example.com' fill_in 'Password', :with=>'apple2' fill_in 'Confirm Password', :with=>'apple2' click_button 'Create Account' visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" end it "should not ask for password again directly after resetting a password, when loading password_grace_period #{before ? "before" : "after"}" do rodauth do features = [:reset_password, :password_grace_period] features.reverse! if before enable :login, :change_login, *features require_login_confirmation? false reset_password_autologin? true end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login(:pass=>'01234567') click_button 'Request Password Reset' link = email_link(/(\/reset-password\?key=.+)$/) visit link fill_in 'Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Reset Password' page.find('#notice_flash').text.must_equal "Your password has been reset" page.current_path.must_equal '/' visit '/change-login' fill_in 'Login', :with=>'foo2@example.com' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" end end it "should ask for password after logging in via remember token" do rodauth do enable :login, :remember, :change_login, :password_grace_period require_login_confirmation? false end roda do |r| r.rodauth rodauth.load_memory r.root do if rodauth.logged_in? if rodauth.logged_in_via_remember_key? view(:content=>"Logged In via Remember") else "Logged In Normally" end else "Not Logged In" end end end login visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' remove_cookie('rack.session') visit '/' page.body.must_include "Logged In via Remember" visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' fill_in 'Password', :with=>'0123456789' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" end end jeremyevans-rodauth-b53f402/spec/password_pepper_spec.rb000066400000000000000000000200321515725514200235470ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth password_pepper feature' do [true, false].each do |ph| it "should use password pepper on login when account_password_hash_column is #{ph}" do pepper = "secret" rodauth do enable :login, :logout, :password_pepper password_pepper { pepper } account_password_hash_column :ph if ph end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end login page.html.must_include "Logged In" pepper = nil logout login page.find("#error_flash").text.must_equal "There was an error logging in" end it "should support rotating password pepper when account_password_hash_column is #{ph}" do pepper = "secret" previous_peppers = [""] rodauth do enable :login, :logout, :password_pepper password_pepper { pepper } previous_password_peppers { previous_peppers } account_password_hash_column :ph if ph end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end login page.html.must_include "Logged In" previous_peppers = [] logout login page.html.must_include "Logged In" previous_peppers = [pepper] pepper = "new secret" logout login page.html.must_include "Logged In" previous_peppers = [] logout login page.html.must_include "Logged In" pepper = "new new secret" logout login page.find("#error_flash").text.must_equal "There was an error logging in" end it "should support not updating old peppers when account_password_hash_column is #{ph}" do pepper = "secret" previous_peppers = [""] rodauth do enable :change_password, :login, :logout, :password_pepper password_pepper { pepper } previous_password_peppers { previous_peppers } password_pepper_update? false account_password_hash_column :ph if ph end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end login page.html.must_include "Logged In" previous_peppers = [] logout login page.find("#error_flash").text.must_equal "There was an error logging in" end it "should use password pepper when changing password when account_password_hash_column is #{ph}" do pepper = nil rodauth do enable :login, :logout, :password_pepper, :change_password password_pepper { pepper } previous_password_peppers [] change_password_requires_password? false account_password_hash_column :ph if ph end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end login page.html.must_include "Logged In" pepper = "secret" visit "/change-password" fill_in "New Password", with: "new password" fill_in "Confirm Password", with: "new password" click_on "Change Password" page.find("#notice_flash").text.must_equal "Your password has been changed" logout login(pass: "new password") page.html.must_include "Logged In" pepper = nil logout login(pass: "new password") page.find("#error_flash").text.must_equal "There was an error logging in" end it "should use password pepper when resetting password when account_password_hash_column is #{ph}" do pepper = "secret" rodauth do enable :login, :logout, :password_pepper, :reset_password password_pepper { pepper } previous_password_peppers [] reset_password_email_sent_redirect "/login" reset_password_redirect "/login" account_password_hash_column :ph if ph end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end visit "/reset-password-request" fill_in "Login", with: "foo@example.com" click_on "Request Password Reset" page.find("#notice_flash").text.must_equal "An email has been sent to you with a link to reset the password for your account" visit email_link(/(\/reset-password\?key=.+)$/) fill_in "Password", with: "new password" fill_in "Confirm Password", with: "new password" click_on "Reset Password" page.find("#notice_flash").text.must_equal "Your password has been reset" login(pass: "new password") page.html.must_include "Logged In" pepper = nil logout login(pass: "new password") page.find("#error_flash").text.must_equal "There was an error logging in" end it "should work without setting password pepper when account_password_hash_column is #{ph}" do rodauth do enable :login, :logout, :password_pepper, :change_password change_password_requires_password? false account_password_hash_column :ph if ph end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end login page.html.must_include "Logged In" visit '/change-password' fill_in 'New Password', :with=>"new password" fill_in 'Confirm Password', :with=>"new password" click_on 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" logout login(pass: "new password") page.html.must_include "Logged In" end end it "should work with disallow_password_reuse feature" do pepper = nil previous_peppers = [""] rodauth do enable :login, :logout, :change_password, :disallow_password_reuse, :password_pepper password_pepper { pepper } previous_password_peppers { previous_peppers } change_password_requires_password? false if ENV['RODAUTH_SEPARATE_SCHEMA'] previous_password_hash_table Sequel[:rodauth_test_password][:account_previous_password_hashes] end end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end login page.html.must_include "Logged In" visit '/change-password' fill_in 'New Password', :with=>"password_1" fill_in 'Confirm Password', :with=>"password_1" click_on 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" pepper = "secret_1" visit '/change-password' fill_in 'New Password', :with=>"password_2" fill_in 'Confirm Password', :with=>"password_2" click_on 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" previous_peppers.unshift pepper pepper = "secret_2" visit '/change-password' fill_in 'New Password', :with=>"password_3" fill_in 'Confirm Password', :with=>"password_3" click_on 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" visit '/change-password' fill_in 'New Password', :with=>"password_2" fill_in 'Confirm Password', :with=>"password_2" click_on 'Change Password' page.find('#error_flash').text.must_equal "There was an error changing your password" previous_peppers.shift visit '/change-password' fill_in 'New Password', :with=>"password_2" fill_in 'Confirm Password', :with=>"password_2" click_on 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" visit '/change-password' fill_in 'New Password', :with=>"password_1" fill_in 'Confirm Password', :with=>"password_1" click_on 'Change Password' page.find('#error_flash').text.must_equal "There was an error changing your password" previous_peppers.shift visit '/change-password' fill_in 'New Password', :with=>"password_1" fill_in 'Confirm Password', :with=>"password_1" click_on 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end end jeremyevans-rodauth-b53f402/spec/path_class_methods_spec.rb000066400000000000000000000015341515725514200242040ustar00rootroot00000000000000require_relative 'spec_helper' describe 'path_class_methods feature' do it "should add *_path and *_url methods as class methods" do rodauth do prefix '/foo' base_url 'https://foo.example.com' enable :path_class_methods, :login, :logout end roda do |r| end app.rodauth.login_path.must_equal '/foo/login' app.rodauth.logout_url.must_equal 'https://foo.example.com/foo/logout' app.rodauth.logout_path('bar'=>'baz').must_equal '/foo/logout?bar=baz' app.rodauth.login_url('bar'=>'baz').must_equal 'https://foo.example.com/foo/login?bar=baz' end it "*_path should work without base_url" do rodauth do enable :path_class_methods, :login, :logout end roda do |r| end app.rodauth.logout_path.must_equal '/logout' proc{app.rodauth.login_url}.must_raise NoMethodError end end jeremyevans-rodauth-b53f402/spec/remember_spec.rb000066400000000000000000000532651515725514200221460ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth remember feature' do it "should support login via remember token" do secret = old_secret = nil raw_before = Time.now - 100000000 rodauth do enable :login, :remember hmac_secret{secret} hmac_old_secret{old_secret} raw_remember_token_deadline{raw_before} end roda do |r| r.rodauth r.get 'load' do rodauth.load_memory r.redirect '/' end r.get 'loadpage' do rodauth.load_memory '' end r.root do if rodauth.logged_in? if rodauth.logged_in_via_remember_key? view :content=>"Logged In via Remember" else view :content=>"Logged In Normally" end else view :content=>"Not Logged In" end end end visit '/loadpage' page.response_headers.wont_include 'Set-Cookie' login page.body.must_include 'Logged In Normally' visit '/load' page.body.must_include 'Logged In Normally' visit '/remember' click_button 'Change Remember Setting' page.find('#error_flash').text.must_equal "There was an error updating your remember setting" choose 'Remember Me' click_button 'Change Remember Setting' page.find('#notice_flash').text.must_equal "Your remember setting has been updated" page.body.must_include 'Logged In Normally' remove_cookie('rack.session') visit '/' page.body.must_include 'Not Logged In' secret = SecureRandom.random_bytes(32) visit '/load' page.body.must_include 'Not Logged In' secret = nil raw_before = Time.now + 100000000 login visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' remove_cookie('rack.session') secret = SecureRandom.random_bytes(32) visit '/load' page.body.must_include 'Logged In via Remember' key = get_cookie('_remember') visit '/remember' choose 'Forget Me' click_button 'Change Remember Setting' page.body.must_include 'Logged In via Remember' remove_cookie('rack.session') visit '/' page.body.must_include 'Not Logged In' visit '/load' page.body.must_include 'Not Logged In' set_cookie('_remember', key.gsub('_', '-')) visit '/load' page.body.must_include 'Not Logged In' set_cookie('_remember', key) visit '/load' page.body.must_include 'Logged In via Remember' visit '/remember' choose 'Disable Remember Me' click_button 'Change Remember Setting' page.body.must_include 'Logged In via Remember' remove_cookie('rack.session') visit '/' page.body.must_include 'Not Logged In' set_cookie('_remember', key) visit '/load' page.body.must_include 'Not Logged In' login visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' secret = SecureRandom.random_bytes(32) remove_cookie('rack.session') visit '/load' page.body.must_include 'Not Logged In' login visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' remove_cookie('rack.session') visit '/load' page.body.must_include 'Logged In via Remember' old_secret = secret secret = SecureRandom.random_bytes(32) remove_cookie('rack.session') visit '/load' page.body.must_include 'Logged In via Remember' old_secret = nil remove_cookie('rack.session') visit '/load' page.body.must_include 'Logged In via Remember' old_secret = SecureRandom.random_bytes(32) secret = SecureRandom.random_bytes(32) remove_cookie('rack.session') visit '/load' page.body.must_include 'Not Logged In' end [true, false].each do |before| it "should forget remember token when explicitly logging out, when loading remember #{before ? "before" : "after"}" do rodauth do features = [:logout, :remember] features.reverse! if before enable :login, *features end roda do |r| r.rodauth r.get 'load' do rodauth.load_memory r.redirect '/' end r.root{rodauth.logged_in? ? "Logged In" : "Not Logged In"} end login page.body.must_equal 'Logged In' visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' page.body.must_equal 'Logged In' logout visit '/' page.body.must_equal 'Not Logged In' visit '/load' page.body.must_equal 'Not Logged In' end end it "should clear remember token when resetting password without a logged in session" do rodauth do enable :login, :reset_password, :remember require_password_confirmation? false reset_password_autologin? false end roda do |r| r.rodauth rodauth.load_memory r.get("remem-and-forget"){rodauth.account_from_session; rodauth.remember_login; rodauth.forget_login; ""} r.root{view :content=>rodauth.logged_in? ? "Logged In!" : "Not Logged"} end login visit '/remem-and-forget' remove_cookie('rack.session') visit '/login' login(:pass=>'01234567', :visit=>false) click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" visit email_link(/(\/reset-password\?key=.+)$/) fill_in 'Password', :with=>'012345678911' DB[:account_remember_keys].count.must_equal 1 click_button "Reset Password" page.find('#notice_flash').text.must_equal "Your password has been reset" page.body.must_include "Not Logged" DB[:account_remember_keys].count.must_equal 0 end it "should clear and set new remember token when changing login in a logged in session logged in via remember token" do rodauth do enable :login, :change_login, :remember require_login_confirmation? false change_login_requires_password? false end roda do |r| r.rodauth rodauth.load_memory r.get("remem"){rodauth.account_from_session; rodauth.remember_login; ""} r.get("via-remember"){rodauth.logged_in_via_remember_key?.to_s} r.root{view :content=>rodauth.logged_in? ? "Logged In!" : "Not Logged"} end login visit '/remem' remove_cookie('rack.session') visit '/via-remember' page.html.must_equal 'true' visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' DB[:account_remember_keys].count.must_equal 1 key1 = DB[:account_remember_keys].get(:key) key1.must_be_kind_of String click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" DB[:account_remember_keys].count.must_equal 1 key2 = DB[:account_remember_keys].get(:key) key2.must_be_kind_of String key1.wont_equal key2 visit '/' page.body.must_include "Logged In!" remove_cookie('rack.session') visit '/via-remember' page.html.must_equal 'true' end it "should set safe default cookie attributes" do cookie_options = {} rodauth do enable :login, :remember, :logout remember_cookie_options { cookie_options } after_login { remember_login } end roda do |r| r.rodauth r.root{rodauth.logged_in? ? "Logged In" : "Not Logged In"} end login retrieve_cookie('_remember') do |cookie| cookie.to_hash["path"].must_equal '/' cookie.secure?.must_equal false cookie.http_only?.must_equal true end logout login :path=>Capybara.default_host.gsub("http://", "https://") + "/login" retrieve_cookie('_remember') do |cookie| cookie.secure?.must_equal true end logout cookie_options = {:path=>nil, :httponly=>false, :secure=>false} login retrieve_cookie('_remember') do |cookie| cookie.to_hash["path"].must_equal '' cookie.http_only?.must_equal false end logout login :path=>Capybara.default_host.gsub("http://", "https://") + "/login" retrieve_cookie('_remember') do |cookie| cookie.secure?.must_equal false end logout end it "should remove cookie if cookie is no longer valid" do rodauth do enable :login, :remember skip_status_checks? false end roda do |r| r.rodauth r.get 'load' do rodauth.load_memory r.redirect '/' end r.root do if rodauth.logged_in? if rodauth.logged_in_via_remember_key? view :content=>"Logged In via Remember" else view :content=>"Logged In Normally" end else view :content=>"Not Logged In" end end end login visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' page.body.must_include 'Logged In Normally' cookie = get_cookie('_remember') remove_cookie('rack.session') rk = DB[:account_remember_keys].first DB[:account_remember_keys].update(:key=>rk[:key][0...-1]) visit '/load' page.body.must_include 'Not Logged In' get_cookie('_remember').must_equal "" DB[:account_remember_keys].delete set_cookie('_remember', cookie) visit '/load' page.body.must_include 'Not Logged In' get_cookie('_remember').must_equal "" DB[:account_remember_keys].insert(rk) DB[:accounts].update(:status_id=>3) set_cookie('_remember', cookie) visit '/load' page.body.must_include 'Not Logged In' get_cookie('_remember').must_equal "" DB[:account_remember_keys].must_be :empty? end it "should support clearing remembered flag" do rodauth do enable :login, :confirm_password, :remember remember_cookie_options :path=>nil end roda do |r| r.rodauth r.get 'load' do rodauth.load_memory r.redirect '/' end r.get 'req-pass' do rodauth.require_password_authentication view :content=>"Password Authentication Passed" end r.root do if rodauth.logged_in? if rodauth.logged_in_via_remember_key? view :content=>"Logged In via Remember" else view :content=>"Logged In Normally" end else view :content=>"Not Logged In" end end end login page.body.must_include 'Logged In Normally' visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' page.body.must_include 'Logged In Normally' remove_cookie('rack.session') visit '/' page.body.must_include 'Not Logged In' visit '/load' page.body.must_include 'Logged In via Remember' visit '/req-pass' page.find('#error_flash').text.must_equal "You need to confirm your password before continuing" visit '/confirm-password' fill_in 'Password', :with=>'012345678' click_button 'Confirm Password' page.find('#error_flash').text.must_equal "There was an error confirming your password" page.html.must_include("invalid password") fill_in 'Password', :with=>'0123456789' click_button 'Confirm Password' page.find('#notice_flash').text.must_equal "Your password has been confirmed" page.body.must_include 'Password Authentication Passed' visit '/' page.body.must_include 'Logged In Normally' end it "should support extending remember token" do rodauth do enable :login, :remember, :logout extend_remember_deadline? true remember_period :days=>30 end roda do |r| r.rodauth r.get 'load' do rodauth.load_memory r.redirect '/' end r.get 'remove' do session.delete(rodauth.remember_deadline_extended_session_key) r.redirect '/' end r.get 'expire' do session[rodauth.remember_deadline_extended_session_key] -= 10000 r.redirect '/' end r.root do if rodauth.logged_in? if rodauth.logged_in_via_remember_key? "Logged In via Remember" else "Logged In Normally" end else "Not Logged In" end end end get_deadline = lambda do deadline = DB[:account_remember_keys].get(:deadline) if deadline.is_a?(String) # Handle jdbc-sqlite times returned as strings in UTC without offset deadline += '+0000' unless Date._parse(deadline).include?(:offset) deadline = Time.parse(deadline) end deadline end login visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' get_deadline.call.must_be(:<, Time.now + 15*86400) DB[:account_remember_keys].update(deadline: Time.now + 10) visit '/expire' visit '/load' deadline = DB[:account_remember_keys].get(:deadline) deadline = Time.parse(deadline) if deadline.is_a?(String) deadline.must_be(:>, Time.now + 29*86400) remove_cookie('rack.session') visit '/' page.body.must_equal 'Not Logged In' old_expiration = cookie_jar.instance_variable_get(:@cookies).first.expires visit '/load' page.body.must_equal 'Logged In via Remember' new_expiration = cookie_jar.instance_variable_get(:@cookies).first.expires new_expiration.must_be :>=, old_expiration get_deadline.call.must_be(:>, Time.now + 29*86400) visit '/remove' DB[:account_remember_keys].update(deadline: Time.now + 10) visit '/load' get_deadline.call.must_be(:>, Time.now + 29*86400) visit '/expire' DB[:account_remember_keys].update(deadline: Time.now + 10) visit '/load' get_deadline.call.must_be(:>, Time.now + 29*86400) # Don't extend remember period if not logged in through remember token. # If someone is logging in manually and not through a remember token, # automatically extending the remember token they are not using increases risk. logout remove_cookie('_remember') DB[:account_remember_keys].update(deadline: Time.now + 10) login visit '/load' get_deadline.call.must_be(:<, Time.now + 20) # Test that load_memory doesn't fail if the account no longer exists # but there is still an remember cookie set. logout login visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' visit '/expire' DB[:account_remember_keys].delete DB[PASSWORD_HASH_TABLE].delete DB[:accounts].delete visit '/load' page.html.must_equal "Not Logged In" end [true, false].each do |before| it "should clear remember token when closing account, when loading remember #{before ? "before" : "after"}" do rodauth do features = [:close_account, :remember] features.reverse! if before enable :login, *features end roda do |r| r.rodauth rodauth.load_memory r.root{rodauth.logged_in? ? "Logged In" : "Not Logged In"} end login visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' DB[:account_remember_keys].count.must_equal 1 visit '/close-account' fill_in 'Password', :with=>'0123456789' click_button 'Close Account' DB[:account_remember_keys].count.must_equal 0 end it "should clear remember token when doing global logout in active_sessions_plugin" do rodauth do features = [:active_sessions, :remember] features.reverse! if before enable :login, *features hmac_secret '123' end roda do |r| r.rodauth rodauth.load_memory r.root{rodauth.logged_in? ? "Logged In" : "Not Logged In"} end login visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' DB[:account_remember_keys].count.must_equal 1 visit '/logout' check 'global-logout' click_button 'Logout' DB[:account_remember_keys].count.must_equal 0 end end it "should not use remember token if the account is not open" do rodauth do enable :login, :remember skip_status_checks? false end roda do |r| r.rodauth r.get 'load' do rodauth.load_memory r.redirect '/' end r.root do if rodauth.logged_in? if rodauth.logged_in_via_remember_key? "Logged In via Remember" else "Logged In Normally" end else "Not Logged In" end end end login page.body.must_equal 'Logged In Normally' visit '/load' page.body.must_equal 'Logged In Normally' visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' page.body.must_equal 'Logged In Normally' remove_cookie('rack.session') visit '/' page.body.must_equal 'Not Logged In' DB[:accounts].update(:status_id=>3) visit '/load' page.body.must_equal 'Not Logged In' end it "should handle uniqueness errors raised when inserting remember token" do rodauth do enable :login, :remember end roda do |r| def rodauth.raised_uniqueness_violation(*) super; true; end r.rodauth r.get 'load' do rodauth.load_memory r.redirect '/' end r.root do if rodauth.logged_in? if rodauth.logged_in_via_remember_key? "Logged In via Remember" else "Logged In Normally" end else "Not Logged In" end end end login visit '/remember' choose 'Remember Me' click_button 'Change Remember Setting' page.body.must_equal 'Logged In Normally' end it "should handle uniqueness errors raised when inserting remember token without there being a valid row" do rodauth do enable :login, :remember end roda do |r| def rodauth.raised_uniqueness_violation(*, &_) StandardError.new; end r.rodauth r.root{''} end login visit '/remember' choose 'Remember Me' proc{click_button 'Change Remember Setting'}.must_raise StandardError end [:jwt, :json].each do |json| it "should support login via remember token via #{json}" do rodauth do enable :login, :confirm_password, :remember end roda(json) do |r| r.rodauth r.post 'load' do rodauth.load_memory [4] end if rodauth.logged_in? if rodauth.logged_in_via_remember_key? [1] else [2] end else [3] end end json_request.must_equal [200, [3]] json_login json_request.must_equal [200, [2]] json_request('/load').must_equal [200, [4]] json_request.must_equal [200, [2]] res = json_request('/remember', :remember=>'remember') res.must_equal [200, {'success'=>"Your remember setting has been updated"}] @authorization = nil @cookie.delete("rack.session") json_request.must_equal [200, [3]] json_request('/load').must_equal [200, [4]] json_request.must_equal [200, [1]] remember_cookie = @cookie["_remember"] res = json_request('/remember', :remember=>'forget') res.must_equal [200, {'success'=>"Your remember setting has been updated"}] json_request.must_equal [200, [1]] @cookie = nil @authorization = nil json_request.must_equal [200, [3]] json_request('/load').must_equal [200, [4]] json_request.must_equal [200, [3]] @cookie = { "_remember" => remember_cookie } json_request('/load').must_equal [200, [4]] json_request.must_equal [200, [1]] res = json_request('/confirm-password', :password=>'123456') res.must_equal [401, {'reason'=>"invalid_password", 'error'=>"There was an error confirming your password", "field-error"=>["password", "invalid password"]}] res = json_request('/confirm-password', :password=>'0123456789') res.must_equal [200, {'success'=>"Your password has been confirmed"}] json_request.must_equal [200, [2]] res = json_request('/remember', :remember=>'disable') res.must_equal [200, {'success'=>"Your remember setting has been updated"}] @authorization = nil @cookie = nil json_request.must_equal [200, [3]] @cookie = { "_remember" => remember_cookie } json_request('/load').must_equal [200, [4]] json_request.must_equal [200, [3]] end end it "should support remember token management via internal requests" do key = nil rodauth do enable :login, :logout, :remember, :internal_request hmac_secret '123' end roda do |r| r.rodauth r.get 'setup' do key = rodauth.class.remember_setup(:account_login=>'foo@example.com') ::Rack::Utils.set_cookie_header!(response.headers, '_remember', :value=>key) '' end r.get 'load' do rodauth.load_memory r.redirect '/' end r.get 'loadpage' do rodauth.load_memory '' end r.root do if rodauth.logged_in? if rodauth.logged_in_via_remember_key? view :content=>"Logged In via Remember" else view :content=>"Logged In Normally" end else view :content=>"Not Logged In" end end end visit '/setup' page.body.must_equal '' app.rodauth.account_id_for_remember_key(:remember=>key).must_equal DB[:accounts].get(:id) proc do app.rodauth.account_id_for_remember_key(:remember=>key[0...-1]) end.must_raise Rodauth::InternalRequestError visit '/' page.body.must_include 'Not Logged In' visit '/load' page.body.must_include 'Logged In via Remember' logout visit '/setup' page.body.must_equal '' app.rodauth.remember_disable(:account_login=>'foo@example.com').must_be_nil proc do app.rodauth.account_id_for_remember_key(:remember=>key) end.must_raise Rodauth::InternalRequestError visit '/load' page.body.must_include 'Not Logged In' end end jeremyevans-rodauth-b53f402/spec/reset_password_notify_spec.rb000066400000000000000000000017351515725514200247770ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth reset_password_notify feature' do it "should send email when password is reset" do rodauth do enable :reset_password_notify end roda do |r| r.rodauth r.root{view :content=>""} end visit '/login' login(:pass=>'01234567', :visit=>false) click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" visit email_link(/(\/reset-password\?key=.+)$/) fill_in 'Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Reset Password' page.find('#notice_flash').text.must_equal "Your password has been reset" email = email_sent email.subject.must_equal "Password Reset Completed" email.body.to_s.must_equal <""} end login(:login=>'foo@example2.com', :pass=>'01234567') page.html.wont_match(/notice_flash/) login(:pass=>'01234567', :visit=>false) click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" page.current_path.must_equal '/' link = email_link(/(\/reset-password\?key=.+)$/) visit '/reset-password' page.find('#error_flash').text.must_equal "There was an error resetting your password: invalid or expired password reset key" visit link[0...-1] page.find('#error_flash').text.must_equal "There was an error resetting your password: invalid or expired password reset key" visit '/login' click_link 'Forgot Password?' page.current_path.must_equal '/reset-password-request' fill_in 'Login', :with=>'foo@example2.com' click_button 'Request Password Reset' page.find('#error_flash').text.must_equal "There was an error requesting a password reset" page.current_path.must_equal '/reset-password-request' page.html.must_include("no matching login") page.all('[type=email]').first.value.must_equal 'foo@example2.com' fill_in 'Login', :with=>'foo@example.com' click_button 'Request Password Reset' email_link(/(\/reset-password\?key=.+)$/).must_equal link login(:pass=>'01234567') click_button 'Request Password Reset' email_link(/(\/reset-password\?key=.+)$/).must_equal link last_sent_column = :email_last_sent login(:pass=>'01234567') click_button 'Request Password Reset' page.find('#error_flash').text.must_equal "An email has recently been sent to you with a link to reset your password" Mail::TestMailer.deliveries.must_equal [] DB[:account_password_reset_keys].update(:email_last_sent => Time.now - 250).must_equal 1 login(:pass=>'01234567') click_button 'Request Password Reset' page.find('#error_flash').text.must_equal "An email has recently been sent to you with a link to reset your password" Mail::TestMailer.deliveries.must_equal [] DB[:account_password_reset_keys].update(:email_last_sent => Time.now - 350).must_equal 1 login(:pass=>'01234567') click_button 'Request Password Reset' email_link(/(\/reset-password\?key=.+)$/).must_equal link visit link page.title.must_equal 'Reset Password' page.find_by_id('password')[:autocomplete].must_equal 'new-password' fill_in 'Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Reset Password' page.html.must_include("passwords do not match") page.find('#error_flash').text.must_equal "There was an error resetting your password" page.current_path.must_equal '/reset-password' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Reset Password' page.body.must_include 'invalid password, same as current password' page.find('#error_flash').text.must_equal "There was an error resetting your password" page.current_path.must_equal '/reset-password' fill_in 'Password', :with=>'012' fill_in 'Confirm Password', :with=>'012' click_button 'Reset Password' page.html.must_include("invalid password, does not meet requirements") page.find('#error_flash').text.must_equal "There was an error resetting your password" page.current_path.must_equal '/reset-password' fill_in 'Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Reset Password' page.find('#notice_flash').text.must_equal "Your password has been reset" page.current_path.must_equal '/' login(:pass=>'0123456') page.current_path.must_equal '/' login(:pass=>'bad') click_link "Forgot Password?" fill_in "Login", :with=>"foo@example.com" click_button "Request Password Reset" DB[:account_password_reset_keys].update(:deadline => Time.now - 60).must_equal 1 link = email_link(/(\/reset-password\?key=.+)$/) visit link page.find('#error_flash').text.must_equal "There was an error resetting your password: invalid or expired password reset key" end end [true, false].each do |convert| it "should support resetting passwords for accounts without confirmation#{' when not converting token ids to integer'}" do rodauth do enable :login, :reset_password require_password_confirmation? false account_id_column{super() if scope} unless convert end roda do |r| r.rodauth r.root{view :content=>""} end visit '/login' login(:pass=>'01234567', :visit=>false) click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" link = email_link(/(\/reset-password\?key=.+)$/) visit link fill_in 'Password', :with=>'0123456' click_button 'Reset Password' page.find('#notice_flash').text.must_equal "Your password has been reset" end end it "should support autologin when resetting passwords for accounts" do rodauth do enable :login, :reset_password reset_password_autologin? true end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login(:pass=>'01234567') click_button 'Request Password Reset' link = email_link(/(\/reset-password\?key=.+)$/) visit link fill_in 'Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Reset Password' page.find('#notice_flash').text.must_equal "Your password has been reset" page.body.must_include("Logged In") end it "invalides login change verify links after password reset" do rodauth do enable :login, :verify_login_change, :reset_password change_login_requires_password? false require_login_confirmation? false require_password_confirmation? false login_meets_requirements?{|login| login.length > 4} end roda do |r| r.rodauth r.root{view :content=>""} end login visit '/login' login(:pass=>'01234567', :visit=>false) click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" reset_password_link = email_link(/(\/reset-password\?key=.+)$/) visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' click_button 'Change Login' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your login change" verify_login_change_link = email_link(/(\/verify-login-change\?key=.+)$/, 'foo3@example.com') visit verify_login_change_link page.title.must_equal 'Verify Login Change' visit reset_password_link fill_in 'Password', :with=>'012345678911' click_button "Reset Password" page.find('#notice_flash').text.must_equal "Your password has been reset" visit verify_login_change_link page.title.must_equal "Login" page.find('#error_flash').text.must_equal "There was an error verifying your login change: invalid verify login change key" end it "should not allow password reset for unverified account" do rodauth do enable :reset_password skip_status_checks? false require_mail? false end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>""} end DB[:accounts].update(:status_id=>1) visit '/reset-password-request' fill_in 'Login', :with=>'foo@example.com' click_button 'Request Password Reset' page.find('#error_flash').text.must_equal "There was an error requesting a password reset" page.html.must_include("unverified account, please verify account before logging in") page.current_path.must_equal '/reset-password-request' end [true, false].each do |before| it "should clear reset password token when closing account, when loading reset_password #{before ? "before" : "after"}" do rodauth do features = [:close_account, :reset_password] features.reverse! if before enable :login, *features reset_password_autologin? true end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login(:pass=>'01234567') click_button 'Request Password Reset' email_link(/(\/reset-password\?key=.+)$/) login DB[:account_password_reset_keys].count.must_equal 1 visit '/close-account' fill_in 'Password', :with=>'0123456789' click_button 'Close Account' DB[:account_password_reset_keys].count.must_equal 0 end end it "should handle uniqueness errors raised when inserting password reset token" do rodauth do enable :login, :reset_password end roda do |r| def rodauth.raised_uniqueness_violation(*) super; true; end r.rodauth r.root{view :content=>""} end login(:pass=>'01234567') click_button 'Request Password Reset' link = email_link(/(\/reset-password\?key=.+)$/) visit link fill_in 'Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Reset Password' page.find('#notice_flash').text.must_equal "Your password has been reset" end it "should reraise uniqueness errors raised when inserting password reset token when token doesn't exist" do rodauth do enable :login, :reset_password end roda do |r| def rodauth.raised_uniqueness_violation(*, &_) StandardError.new; end r.rodauth r.root{view :content=>""} end login(:pass=>'01234567') proc{click_button 'Request Password Reset'}.must_raise StandardError end it "should not display reset password request link on login page if route is disabled" do route = 'reset-password-request' rodauth do enable :login, :reset_password reset_password_request_route { route } end roda do |r| r.rodauth end visit '/login' click_on 'Forgot Password?' page.current_path.must_equal '/reset-password-request' route = nil visit '/login' page.html.wont_include "Forgot Password?" end [:jwt, :json].each do |json| it "should support resetting passwords for accounts via #{json}" do rodauth do enable :login, :reset_password reset_password_email_body{reset_password_email_link} null_byte_parameter_value{|_, v| v} end roda(json) do |r| r.rodauth end res = json_login(:pass=>'1', :no_check=>true) res.must_equal [401, {'reason'=>"invalid_password","field-error"=>["password", "invalid password"], "error"=>"There was an error logging in"}] res = json_request('/reset-password') res.must_equal [401, {"reason"=>"invalid_reset_password_key", "error"=>"There was an error resetting your password"}] res = json_request('/reset-password-request', :login=>'foo@example2.com') res.must_equal [401, {'reason'=>"no_matching_login","field-error"=>["login", "no matching login"], "error"=>"There was an error requesting a password reset"}] res = json_request('/reset-password-request', :login=>'foo@example.com') res.must_equal [200, {"success"=>"An email has been sent to you with a link to reset the password for your account"}] link = email_link(/key=.+$/) res = json_request('/reset-password', :key=>link[4...-1]) res.must_equal [401, {"reason"=>"invalid_reset_password_key", "error"=>"There was an error resetting your password"}] res = json_request('/reset-password', :key=>link[4..-1], :password=>'ab1234561', "password-confirm"=>'ab1234562') res.must_equal [422, {'reason'=>"passwords_do_not_match","error"=>"There was an error resetting your password", "field-error"=>["password", 'passwords do not match']}] res = json_request('/reset-password', :key=>link[4..-1], :password=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [422, {'reason'=>"same_as_existing_password","error"=>"There was an error resetting your password", "field-error"=>["password", 'invalid password, same as current password']}] res = json_request('/reset-password', :key=>link[4..-1], :password=>'1', "password-confirm"=>'1') res.must_equal [422, {'reason'=>"password_too_short","error"=>"There was an error resetting your password", "field-error"=>["password", "invalid password, does not meet requirements (minimum 6 characters)"]}] res = json_request('/reset-password', :key=>link[4..-1], :password=>"\0ab123456", "password-confirm"=>"\0ab123456") res.must_equal [422, {'reason'=>"password_contains_null_byte","error"=>"There was an error resetting your password", "field-error"=>["password", "invalid password, does not meet requirements (contains null byte)"]}] res = json_request('/reset-password', :key=>link[4..-1], :password=>'0123456', "password-confirm"=>'0123456') res.must_equal [200, {"success"=>"Your password has been reset"}] json_login(:pass=>'0123456') end end it "should support requesting password resets using an internal request" do rodauth do enable :login, :logout, :reset_password, :internal_request reset_password_email_last_sent_column nil domain 'example.com' internal_request_configuration do csrf_tag { |*| fail "must not rely on Roda session" } end end roda do |r| r.rodauth r.root{view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged")} end proc do app.rodauth.login(:login=>'foo@example.com', :password=>'invalid') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.reset_password_request(:login=>'foo3@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.reset_password_request(:account_login=>'foo3@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.reset_password(:account_login=>'foo3@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.reset_password(:account_login=>'foo@example.com') end.must_raise Rodauth::InternalRequestError app.rodauth.reset_password_request(:login=>'foo@example.com').must_be_nil link = email_link(/(\/reset-password\?key=.+)$/) app.rodauth.reset_password_request(:account_login=>'foo@example.com').must_be_nil link2 = email_link(/(\/reset-password\?key=.+)$/) link2.must_equal link visit link fill_in 'Password', :with=>'0123456' fill_in 'Confirm Password', :with=>'0123456' click_button 'Reset Password' page.find('#notice_flash').text.must_equal "Your password has been reset" login(:pass=>'0123456') page.body.must_include "Logged In" logout app.rodauth.reset_password_request(:account_login=>'foo@example.com').must_be_nil email_link(/(\/reset-password\?key=.+)$/) app.rodauth.reset_password(:account_login=>'foo@example.com', :password=>'01234567').must_be_nil login(:pass=>'01234567') page.body.must_include "Logged In" logout app.rodauth.reset_password_request(:login=>'foo@example.com').must_be_nil link = email_link(/(\/reset-password\?key=.+)$/) app.rodauth.reset_password(:account_login=>'foo@example.com', :password=>'012345678').must_be_nil visit link page.find('#error_flash').text.must_equal "There was an error resetting your password: invalid or expired password reset key" login(:pass=>'012345678') page.body.must_include "Logged In" app.rodauth.reset_password_request(:login=>'foo@example.com').must_be_nil link = email_link(/(\/reset-password\?key=.+)$/) key = link.split('=').last proc do app.rodauth.reset_password(:reset_password_key=>key[0...-1], :password=>'0123456789').must_be_nil end.must_raise Rodauth::InternalRequestError app.rodauth.reset_password(:reset_password_key=>key, :password=>'0123456789').must_be_nil login(:pass=>'0123456789') page.body.must_include "Logged In" end end jeremyevans-rodauth-b53f402/spec/reset_password_verifies_account_spec.rb000066400000000000000000000024341515725514200270140ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth reset_password_verifies_account feature' do it "should support implicit verification when resetting passwords for unverified accounts" do rodauth do enable :login, :logout, :reset_password_verifies_account reset_password_autologin? true end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end DB[:accounts].update(:status_id=>1) DB[:account_verification_keys].insert(:id=>DB[:accounts].get(:id), :key=>'test') 2.times do |i| visit '/reset-password-request' fill_in 'Login', :with=>'foo@example.com' click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" link = email_link(/(\/reset-password\?key=.+)$/) visit link fill_in 'Password', :with=>"0123456#{i}" fill_in 'Confirm Password', :with=>"0123456#{i}" click_button 'Reset Password' page.find('#notice_flash').text.must_equal "Your password has been reset" page.body.must_include("Logged In") DB[:accounts].get(:status_id).must_equal 2 DB[:account_verification_keys].count.must_equal 0 logout end end end jeremyevans-rodauth-b53f402/spec/rodauth_spec.rb000066400000000000000000001164201515725514200220070ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth' do it "should keep private methods private when overridden" do rodauth do use_database_authentication_functions? false end roda do |r| rodauth.use_database_authentication_functions?.to_s end proc{visit '/'}.must_raise NoMethodError end it "should ignore parameters with null bytes" do null_nil = true rodauth do enable :login null_byte_parameter_value do |_, v| null_nil ? super(_, v) : v.delete("\0") end end roda do |r| # Set null byte here to avoid Nokogiri error r.params['login'] = "foo\0@example.com" if r.request_method == 'POST' r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end login page.find('#error_flash').text.must_equal 'There was an error logging in' page.html.must_include("no matching login") null_nil = false login page.body.must_include 'Logged In' end it "should ignore parameters over max bytesize" do over_max = nil rodauth do enable :login over_max_bytesize_param_value do |_, v| over_max ? v[0, 15] : super(_, v) end end roda do |r| r.rodauth next unless rodauth.logged_in? r.root{view :content=>"Logged In"} end login = 'foo@example.com'+'a'*1024 login(:login=>login) page.find('#error_flash').text.must_equal 'There was an error logging in' page.html.must_include("no matching login") over_max = true login(:login=>login) page.body.must_include 'Logged In' end it "should support template_opts" do rodauth do enable :login template_opts(:layout_opts=>{:path=>'spec/views/layout-other.str'}) end roda do |r| r.rodauth end visit '/login' page.title.must_equal 'Foo Login' end it "should disabled setting default_fixed_locals if use_template_fixed_locals? false" do rodauth do enable :login use_template_fixed_locals? false end roda do |r| r.rodauth end if app.render_opts[:template_opts][:default_fixed_locals] proc{visit '/login'}.must_raise ArgumentError else visit '/login' page.title.must_equal 'Login' end end it "should alstringlow overriding fixed_locals via template_opts" do rodauth do enable :login template_opts(template_opts: {fixed_locals: "()"}) end roda do |r| r.rodauth end if app.render_opts[:template_opts][:default_fixed_locals] proc{visit '/login'}.must_raise ArgumentError else visit '/login' page.title.must_equal 'Login' end end unless defined?(Tilt.extract_fixed_locals) && !defined?(Roda::RodaPlugins::Render::FIXED_LOCALS_COMPILED_METHOD_SUPPORT) it "should support flash_error_key and flash_notice_key" do rodauth do enable :login template_opts(:layout_opts=>{:path=>'spec/views/layout-other.str'}) flash_error_key 'error2' flash_notice_key 'notice2' end roda do |r| r.rodauth rodauth.require_login view(:content=>'', :layout_opts=>{:path=>'spec/views/layout-other.str'}) end visit '/' page.html.must_include 'Please login to continue' login(:visit=>false) page.html.must_include 'You have been logged in' end it "should not prefix flash_error_key and flash_notice_key with session key prefix" do rodauth do enable :login session_key_prefix :foo end roda do |r| r.rodauth rodauth.require_login view(:content=>'') end visit '/' page.html.must_include 'Please login to continue' login(:visit=>false) page.html.must_include 'You have been logged in' end it "should support customizing titles for views" do rodauth do enable :login, :reset_password login_page_title 'FooLogin' reset_password_request_page_title 'FooRP' end roda do |r| r.rodauth end visit '/login' page.title.must_equal 'FooLogin' visit '/reset-password-request' page.title.must_equal 'FooRP' end it "should support loading rodauth plugin twice in same class" do @no_freeze = true rodauth do enable :login login_page_title 'FooLogin' end roda do |r| r.rodauth end @app.plugin :rodauth do enable :reset_password reset_password_request_page_title 'FooRP' end visit '/login' page.title.must_equal 'FooLogin' visit '/reset-password-request' page.title.must_equal 'FooRP' end it "should reuse the configuration object" do @no_freeze = true rodauth do enable :login login_page_title 'My Title' end roda do |r| r.rodauth end @app.plugin :rodauth do login_button 'My Button' end visit '/login' page.title.must_equal 'My Title' page.find("[type=submit]").value.must_equal 'My Button' end it "should support multi-level inheritance" do require "rodauth" base = Class.new(Rodauth::Auth) do configure do enable :login, :path_class_methods end end auth1 = Class.new(base) do configure do enable :http_basic_auth login_route "signin" end end auth2 = Class.new(base) do configure do enable :logout prefix "/auth" end end app = Class.new(Roda) app.plugin :rodauth, auth_class: base app.plugin :rodauth, auth_class: auth1, name: :auth1 app.plugin :rodauth, auth_class: auth2, name: :auth2 base.features.must_equal [:login, :path_class_methods] base.login_path.must_equal "/login" base.routes.must_equal [:handle_login] base.route_hash.must_equal({ "/login" => :handle_login }) auth1.features.must_equal [:login, :path_class_methods, :http_basic_auth] auth1.login_path.must_equal "/signin" auth1.routes.must_equal [:handle_login] auth1.route_hash.must_equal({ "/signin" => :handle_login }) auth2.features.must_equal [:login, :path_class_methods, :logout] auth2.login_path.must_equal "/auth/login" auth2.routes.must_equal [:handle_login, :handle_logout] auth2.route_hash.must_equal({ "/login" => :handle_login, "/logout" => :handle_logout }) end it "should support internal_request_configuration inheritance" do require "rodauth" base = Class.new(Rodauth::Auth) do configure do enable :login, :path_class_methods, :internal_request internal_request_configuration do login_page_title { super() + " - Base" } end end end auth1 = Class.new(base) do configure do enable :http_basic_auth login_route "signin" login_page_title { "Auth 1" } end end auth2 = Class.new(base) do configure do enable :logout prefix "/auth" internal_request_configuration do logout_page_title { super() + " - Sub 1" } end end end app = Class.new(Roda) app.plugin :rodauth, auth_class: base app.plugin :rodauth, auth_class: auth1, name: :auth1 app.plugin :rodauth, auth_class: auth2, name: :auth2 base.features.must_equal [:login, :path_class_methods, :internal_request] base.login_path.must_equal "/login" base.routes.must_equal [:handle_login] base.route_hash.must_equal({ "/login" => :handle_login }) base.internal_request_eval { login_page_title }.must_equal("Login - Base") auth1.features.must_equal [:login, :path_class_methods, :internal_request, :http_basic_auth] auth1.login_path.must_equal "/signin" auth1.routes.must_equal [:handle_login] auth1.route_hash.must_equal({ "/signin" => :handle_login }) auth1.internal_request_eval { login_page_title }.must_equal("Auth 1 - Base") auth2.features.must_equal [:login, :path_class_methods, :internal_request, :logout] auth2.login_path.must_equal "/auth/login" auth2.routes.must_equal [:handle_login, :handle_logout] auth2.route_hash.must_equal({ "/login" => :handle_login, "/logout" => :handle_logout }) auth2.internal_request_eval { login_page_title }.must_equal("Login - Base") auth2.internal_request_eval { logout_page_title }.must_equal("Logout - Sub 1") end it "should allow setting Rodauth::Auth subclass with :auth_class option" do require "rodauth" auth_class = Class.new(Rodauth::Auth) rodauth do enable :login end roda(auth_class: auth_class) do |r| r.rodauth end @app.rodauth.must_equal auth_class auth_class.features.must_include :login end it "should set configuration name for anonymous classes" do @no_precompile = true rodauth do enable :login end roda(name: :admin) do |r| r.rodauth end @app.rodauth(:admin).configuration_name.must_equal :admin end it "should set configuration name for provided auth classes" do require "rodauth" auth_class = Class.new(Rodauth::Auth) @no_precompile = @no_freeze = true rodauth do enable :login end roda(auth_class: auth_class, name: :admin) do |r| r.rodauth end app.plugin(:rodauth, auth_class: auth_class, name: :admin2) auth_class.configuration_name.must_equal :admin end it "should set configuration name for internal request classes" do @no_precompile = @no_freeze = true rodauth do enable :internal_request end roda(name: :admin) do |r| r.rodauth end app.plugin(:rodauth, auth_class: Class.new(Rodauth::Auth), name: :secondary) do enable :internal_request end app.rodauth(:admin).const_get(:InternalRequest).configuration_name.must_equal :admin app.rodauth(:secondary).const_get(:InternalRequest).configuration_name.must_equal :secondary end it "should not require passing a block when loading the plugin" do app = Class.new(Base) app.plugin :rodauth app.rodauth.superclass.must_equal(Rodauth::Auth) end it "should support route paths and URLs with prefix and query parameters" do block = proc{''} prefix = '' rodauth do enable :login prefix { prefix } end roda do |r| view :content=>instance_exec(&block) end block = proc{rodauth.login_path} visit '/' page.text.must_equal '/login' prefix = '/auth' visit '/' page.text.must_equal '/auth/login' block = proc{rodauth.login_path(a: 'b c')} visit '/' page.text.must_equal '/auth/login?a=b+c' block = proc{rodauth.login_path(a: 'b', c: 'd')} visit '/' page.text.must_equal '/auth/login?a=b&c=d' block = proc{rodauth.login_path(a: ['b', 'c'])} visit '/' ["/auth/login?a%5B%5D=b&a%5B%5D=c", '/auth/login?a[]=b&a[]=c'].must_include page.text block = proc{rodauth.login_url} prefix = '' visit '/' page.text.must_equal 'http://www.example.com/login' prefix = '/auth' visit '/' page.text.must_equal 'http://www.example.com/auth/login' block = proc{rodauth.login_url(a: 'b c')} visit '/' page.text.must_equal 'http://www.example.com/auth/login?a=b+c' block = proc{rodauth.login_url(a: 'b', c: 'd')} visit '/' page.text.must_equal 'http://www.example.com/auth/login?a=b&c=d' block = proc{rodauth.login_url(a: ['b', 'c'])} visit '/' ['http://www.example.com/auth/login?a%5B%5D=b&a%5B%5D=c', 'http://www.example.com/auth/login?a[]=b&a[]=c'].must_include page.text end it "should set current route" do rodauth do enable :login login_additional_form_tags { "#{current_route}" } end roda do |r| r.rodauth r.root { view(:content=>"Current route: #{rodauth.current_route.inspect}") } end visit '/login' page.find("#current-route").text.must_equal "login" click_on 'Login' page.find("#current-route").text.must_equal "login" visit '/' page.text.must_equal "Current route: nil" end it "should support disabling routes" do rodauth do enable :create_account, :internal_request create_account_route nil login_route false end @no_freeze = true roda do |r| r.rodauth r.root { "create_account_path: #{rodauth.create_account_path.inspect}, create_account_url: #{rodauth.create_account_url.inspect}" } end @app.not_found { "not found" } visit '/create-account' page.html.must_equal "not found" visit '/' page.html.must_equal "create_account_path: nil, create_account_url: nil" @app.rodauth.route_hash.must_equal({}) @app.rodauth.create_account(login: "user@example.com", password: "secret") @app.rodauth.account_exists?(login: "user@example.com").must_equal true end it "should support session key prefix" do rodauth do session_key_prefix "prefix_" end roda do |r| r.root { rodauth.session_key.inspect } end visit '/' if app.opts[:sessions_convert_symbols] page.html.must_equal "\"prefix_account_id\"" else page.html.must_equal ":prefix_account_id" end end it "should support translation" do rodauth do enable :login translate do |key, value| "#{key}-#{value}" end end roda do |r| r.rodauth view :content=>'' end visit '/login' page.title.must_equal 'login_page_title-Login' fill_in "login_label-Logininput_field_label_suffix-", :with=>'foo@example.com' fill_in "password_label-Passwordinput_field_label_suffix-", :with=>'0123456789' click_button 'login_button-Login' page.current_path.must_equal '/' page.find('#notice_flash').text.must_equal 'login_notice_flash-You have been logged in' end it "should work without preloading the templates" do @no_precompile = true rodauth do enable :login end roda do |r| r.rodauth end visit '/login' page.title.must_equal 'Login' end it "should warn when using deprecated configuration methods" do warning = nil rodauth do enable :email_auth define_singleton_method(:warn) do |*a| warning = a.first end auth_class_eval do define_method(:warn) do |*a| warning = a.first end private :warn end Rodauth::EmailAuth.send(:def_deprecated_alias, :no_matching_email_auth_key_error_flash, :no_matching_email_auth_key_message) no_matching_email_auth_key_message 'foo' end roda do |r| rodauth.no_matching_email_auth_key_message end warning.must_equal "Deprecated no_matching_email_auth_key_message method used during configuration, switch to using no_matching_email_auth_key_error_flash" visit '/' body.must_equal 'foo' warning.must_equal "Deprecated no_matching_email_auth_key_message method called at runtime, switch to using no_matching_email_auth_key_error_flash" end it "should pick up template changes if not caching templates" do begin @no_freeze = true cache = true rodauth do enable :login cache_templates{cache} end roda do |r| r.rodauth end dir = 'spec/views2' file = "#{dir}/login.str" app.plugin :render, :views=>dir, :engine=>'str' Dir.mkdir(dir) unless File.directory?(dir) text = File.read('spec/views/login.str') File.open(file, 'wb'){|f| f.write text} visit '/login' page.all('label').first.text.must_equal 'Login' File.open(file, 'wb'){|f| f.write text.gsub('Login', 'Banana')} visit '/login' page.all('label').first.text.must_equal 'Login' cache = false visit '/login' page.all('label').first.text.must_equal 'Banana' ensure File.delete(file) if File.file?(file) Dir.rmdir(dir) if File.directory?(dir) end end it "should handle overridding button and password field templates" do rodauth do enable :login end @no_precompile = true no_freeze! roda do |r| r.rodauth view(:content=>rodauth.logged_in? ? "Logged In" : "Not Logged") end app.plugin :render, :views=>'spec/override-views', :engine=>'str' visit '/login' fill_in 'Login', :with=>'foo@example.com' fill_in 'Actual Password', :with=>'0123456789' click_button 'Actually Login' page.find('#notice_flash').text.must_equal 'You have been logged in' page.html.must_include 'Logged In' end it "should require login to perform certain actions" do rodauth do enable :login, :change_password, :change_login, :close_account end roda do |r| r.rodauth r.is "a" do rodauth.require_login end end visit '/change-password' page.current_path.must_equal '/login' visit '/change-login' page.current_path.must_equal '/login' visit '/close-account' page.current_path.must_equal '/login' visit '/a' page.current_path.must_equal '/login' end it "should support requiring account" do rodauth do enable :login end roda do |r| r.rodauth r.is "a" do rodauth.require_account r.redirect "/" end r.root do view :content=>"Logged in: #{!!rodauth.logged_in?}" end end visit "/login" fill_in "Login", :with=>"foo@example.com" fill_in "Password", :with=>"0123456789" click_on "Login" page.current_path.must_equal "/" page.body.must_include "Logged in: true" visit "/a" page.current_path.must_equal "/" page.body.must_include "Logged in: true" DB[PASSWORD_HASH_TABLE].delete DB[:accounts].delete visit "/a" page.current_path.must_equal "/login" page.find("#error_flash").text.must_equal "Please login to continue" visit "/" page.body.must_include "Logged in: false" visit "/a" page.current_path.must_equal "/login" page.find("#error_flash").text.must_equal "Please login to continue" end it "should support retrieving current account" do rodauth do enable :login end roda do |r| r.rodauth r.root do view :content=>"Current email: #{rodauth.account! && rodauth.account[:email]}" end end visit "/" page.body.must_include "Current email: \n" login page.body.must_include "Current email: foo@example.com" instance = app.rodauth.allocate instance.instance_eval { account_from_login("foo@example.com") } instance.account![:email].must_equal "foo@example.com" end it "should handle case where account is no longer valid during session" do rodauth do enable :login, :change_password already_logged_in{request.redirect '/'} skip_status_checks? false end roda do |r| r.rodauth r.root do view :content=>(rodauth.logged_in? ? "Logged In" : "Not Logged") end end login page.body.must_include("Logged In") DB[:accounts].update(:status_id=>3) visit '/change-password' page.current_path.must_equal '/login' visit '/' page.body.must_include("Not Logged") end it "should handle cases where you are already logged in on pages that don't expect a login" do rodauth do enable :login, :logout, :create_account, :reset_password, :verify_account already_logged_in{request.redirect '/'} end roda do |r| r.rodauth r.root do view :content=>'' end end login visit '/login' page.current_path.must_equal '/' visit '/create-account' page.current_path.must_equal '/' visit '/reset-password' page.current_path.must_equal '/' visit '/verify-account' page.current_path.must_equal '/' visit '/logout' page.current_path.must_equal '/logout' end it "should have rodauth.session_value work when not logged in" do rodauth do enable :login end roda do |r| rodauth.session_value.inspect end visit '/' page.body.must_equal 'nil' end it "should have rodauth.features return list of enabled features" do rodauth do enable :create_account, :verify_account, :login end roda do |r| rodauth.features.join(",") end visit '/' if RODAUTH_ALWAYS_ARGON2 page.body.must_equal 'login_password_requirements_base,argon2,login,create_account,email_base,verify_account' else page.body.must_equal 'login,login_password_requirements_base,create_account,email_base,verify_account' end end it "should allow enabling custom features that have already been loaded" do require "rodauth" Rodauth::Feature.define(:foo) {} rodauth do enable :foo end roda do |r| rodauth.features.join(",") end visit '/' if RODAUTH_ALWAYS_ARGON2 page.body.must_equal 'login_password_requirements_base,argon2,foo' else page.body.must_equal 'foo' end Rodauth::FEATURES.delete(:foo) end it "should support auth_class_eval for evaluation inside Auth class" do rodauth do enable :login login_label{foo} auth_class_eval do def foo 'Lonig' end end end roda do |r| r.rodauth end visit '/login' fill_in 'Lonig', :with=>'foo@example.com' end ["", " when using default_rodauth_name"].each do |type| use_default_rodauth_name = type != "" it "should support multiple rodauth configurations in an app#{type}" do app = Class.new(Base) app.plugin(:rodauth, rodauth_opts) do enable :argon2 if RODAUTH_ALWAYS_ARGON2 enable :login if ENV['RODAUTH_SEPARATE_SCHEMA'] password_hash_table Sequel[:rodauth_test_password][:account_password_hashes] function_name do |name| "rodauth_test_password.#{name}" end end end app.plugin(:rodauth, rodauth_opts.merge(:name=>:r2)) do enable :logout end if Minitest::HooksSpec::USE_ROUTE_CSRF app.plugin :route_csrf, Minitest::HooksSpec::ROUTE_CSRF_OPTS end if use_default_rodauth_name app.send(:define_method, :default_rodauth_name){request.path.start_with?('/r2') ? :r2 : nil} end app.route do |r| if Minitest::HooksSpec::USE_ROUTE_CSRF check_csrf! end r.on 'r1' do r.rodauth 'r1' end r.on 'r2' do if use_default_rodauth_name r.rodauth else r.rodauth(:r2) end 'r2' end rodauth.session_value.inspect end app.freeze self.app = app login(:path=>'/r1/login') page.body.must_equal DB[:accounts].get(:id).inspect visit '/r2/logout' click_button 'Logout' page.body.must_equal 'nil' visit '/r1/logout' page.body.must_equal 'r1' visit '/r2/login' page.body.must_equal 'r2' end end it "should support account_select setting for choosing account columns" do rodauth do enable :login account_select [:id, :email] end roda do |r| r.rodauth rodauth.account_from_session rodauth.account.keys.map(&:to_s).sort.join(' ') end login page.body.must_equal 'email id' end it "should support :csrf=>false and :flash=>false and :render=> false plugin options" do c = Class.new(Roda) c.plugin(:rodauth, :csrf=>false, :flash=>false, :render=>false){} c.route{} c.instance_variable_get(:@middleware).length.must_equal 0 c.ancestors.map(&:to_s).wont_include 'Roda::RodaPlugins::Render::InstanceMethods' c.ancestors.map(&:to_s).wont_include 'Roda::RodaPlugins::Flash::InstanceMethods' c.ancestors.map(&:to_s).wont_include 'Roda::RodaPlugins::RouteCsrf::InstanceMethods' end it "raises error when rendering is disabled" do c = Class.new(Roda) c.plugin(:rodauth, :render=>false){} rodauth = c.new({}).rodauth error = proc{rodauth.render("login")}.must_raise Rodauth::ConfigurationError error.message.must_include "attempted to render" end it "should inherit rodauth configuration in subclass" do auth_class = nil no_freeze! rodauth{auth_class = auth} roda(:csrf=>false, :flash=>false){|r|} Class.new(app).rodauth.must_equal auth_class end it "should use subclass of rodauth configuration if modifying rodauth configuration in subclass" do auth_class = nil no_freeze! rodauth{auth_class = auth; auth_class_eval{def foo; 'foo' end}} roda{|r| rodauth.foo} visit '/' page.html.must_equal 'foo' a = Class.new(app) a.plugin(:rodauth, rodauth_opts){auth_class_eval{def foo; "#{super}bar" end}} a.rodauth.superclass.must_equal auth_class visit '/' page.html.must_equal 'foo' self.app = a visit '/' page.html.must_equal 'foobar' end it "should work when not using CSRF or bcrypt" do rodauth do enable :login require_bcrypt? false account_password_hash_column :foo end roda(:no_csrf) do |r| r.rodauth end login page.find('#error_flash').text.must_equal 'There was an error logging in' page.html.must_include("invalid password") end it "should use correct values for some internal methods" do auth = nil rodauth do enable :login template_opts(:locals=>{a: 1}, :template_opts=>{:fixed_locals=>false}) end roda(:no_csrf) do |r| r.rodauth auth = rodauth view :content=>"Possible Authentication Methods: #{rodauth.possible_authentication_methods.join(' ')}." end visit '/' page.html.must_include("Possible Authentication Methods: .") proc{auth.send(:account_ds, nil)}.must_raise ArgumentError login page.html.must_include("Possible Authentication Methods: password.") auth.send(:password_hash_ds).get(:id).must_be_kind_of(ENV['RODAUTH_SPEC_UUID'] && DB.database_type == :postgres ? String : Integer) auth.send(:convert_timestamp, "2020-10-12 12:00:00").strftime('%Y-%m-%d').must_equal '2020-10-12' end it "should run route hooks" do hooks = [] rodauth do enable :login before_rodauth do hooks << :before end around_rodauth do |&block| begin hooks << :before_around super(&block) ensure hooks << :after_around end end end roda do |r| r.rodauth end visit '/login' hooks.must_equal [:before_around, :before, :after_around] end { 'should allow different configurations for internal requests'=>true, 'should allow use of internal_request? to determine whether this is an internal request'=>false }.each do |desc, use_internal_request_predicate| it desc do rodauth do enable :login, :logout, :create_account, :internal_request require_login_confirmation? false require_password_confirmation? false if use_internal_request_predicate login_minimum_length{internal_request? ? 9 : 15} password_minimum_length{internal_request? ? 3 : super()} else login_minimum_length 15 internal_request_configuration do login_minimum_length 9 end internal_request_configuration do password_minimum_length 3 end end end roda do |r| r.rodauth view :content=>"" end visit '/create-account' fill_in 'Login', :with=>'foo@e.com' fill_in 'Password', :with=>'012' click_button 'Create Account' page.html.must_include("invalid login, minimum 15 characters") page.find('#error_flash').text.must_equal "There was an error creating your account" fill_in 'Login', :with=>'foo@e123456789.com' fill_in 'Password', :with=>'012' click_button 'Create Account' page.html.must_include("invalid password, does not meet requirements (minimum 6 characters)") page.find('#error_flash').text.must_equal "There was an error creating your account" fill_in 'Password', :with=>'123456' click_button 'Create Account' page.find('#notice_flash').text.must_equal "Your account has been created" login(:login=>'foo@e123456789.com', :pass=>'123456') page.find('#notice_flash').text.must_equal 'You have been logged in' logout app.rodauth.create_account(:login=>'foo@f.com', :password=>'012').must_be_nil proc do app.rodauth.create_account(:login=>'foo@e.com', :password=>'12') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.create_account(:login=>'f@e.com', :password=>'012') end.must_raise Rodauth::InternalRequestError login(:login=>'foo@f.com', :pass=>'012') page.find('#notice_flash').text.must_equal 'You have been logged in' end end it "should allow custom options when creating internal requests" do rodauth do enable :login, :logout, :create_account, :change_login, :internal_request before_create_account_route do params[login_param] += param('name') + request.env[:at] + session[:domain] end before_change_login_route do params[login_param] += authenticated_by.first end end roda do |r| r.rodauth view :content=>"" end app.rodauth.create_account(:login=>'foo', :password=>'0123456789', :params=>{'name'=>'bar'}, :env=>{:at=>'@'}, :session=>{:domain=>'g.com'}).must_be_nil login(:login=>'foobar@g.com') page.find('#notice_flash').text.must_equal 'You have been logged in' logout app.rodauth.change_login(:account_id=>DB[:accounts].where(:email=>'foobar@g.com').get(:id), :login=>'foo@h.', :authenticated_by=>['com']).must_be_nil login(:login=>'foo@h.com') page.find('#notice_flash').text.must_equal 'You have been logged in' end it "should warn for invalid options for internal requests" do warning = nil rodauth do enable :login, :logout, :create_account, :internal_request auth_class_eval do define_singleton_method(:warn){|*a| warning = a} end end roda do |r| r.rodauth view :content=>"" end app.rodauth.create_account(:login=>'foo@h.com', :password=>'0123456789', :banana=>:pear).must_be_nil warning[0].must_match(/\Aunhandled options passed to create_account: {:?banana(=>|: ):pear}\z/) warning.length.must_equal 1 login(:login=>'foo@h.com') page.find('#notice_flash').text.must_equal 'You have been logged in' end it "should assign internal request subclass to a constant" do rodauth do enable :internal_request end roda do |r| r.rodauth end instance = app.rodauth.internal_request_eval { self } app.rodauth.const_get(:InternalRequest).must_equal instance.class instance.class.name.must_equal "#{app.rodauth}::InternalRequest" if RUBY_VERSION >= '3' instance.class.superclass.must_equal app.rodauth end it "should allow loading internal_request outside of plugin block" do require "rodauth" auth_class = Class.new(Rodauth::Auth) do configure do enable :internal_request, :login end end Class.new(Roda) do plugin :rodauth, auth_class: auth_class end auth_class.account_exists?(login: "foo@example.com").must_equal true auth_class.features.must_equal [:internal_request, :login] end it "should use domain when generating URLs" do rodauth do enable :login, :logout, :verify_account, :internal_request domain "foo.com" internal_request_configuration do domain "bar.com" end end roda do |r| r.rodauth view :content=>"" end visit "/create-account" fill_in "Login", with: "user@foo.com" click_on "Create Account" email_link(/http:\/\/foo\.com\/verify-account/, "user@foo.com") app.rodauth.create_account(login: "user@bar.com") email_link(/https:\/\/bar\.com\/verify-account/, "user@bar.com") app.rodauth.create_account(login: "user2@bar.com", :env=>{'SERVER_PORT'=>444, 'HTTP_HOST'=>'example.com:444'}) email_link(/https:\/\/bar\.com:444\/verify-account/, "user2@bar.com") end it "should raise error unless domain is set" do rodauth do enable :login, :logout, :verify_account, :internal_request end roda do |r| r.rodauth view :content=>"" end proc do app.rodauth.create_account(:login=>'foo@h.com', :password=>'0123456789') end.must_raise Rodauth::InternalRequestError end it "should set attributes on internal request error" do rodauth do enable :create_account, :internal_request end roda do |r| end error = proc do app.rodauth.create_account(login: "foo", password: "secret") end.must_raise Rodauth::InternalRequestError [ 'There was an error creating your account (login_not_valid_email, {"login"=>"invalid login, not a valid email address"})', 'There was an error creating your account (login_not_valid_email, {"login" => "invalid login, not a valid email address"})' ].must_include error.message error.flash.must_equal "There was an error creating your account" error.reason.must_equal :login_not_valid_email error.field_errors.must_equal({ "login" => "invalid login, not a valid email address" }) end it "should handle direct calls to _handle_internal_request_error with just error reason" do rodauth do enable :create_account, :internal_request before_create_account do set_error_reason(:foo) _handle_internal_request_error end end roda do |r| end error = proc do app.rodauth.create_account(login: "foo@example2.com", password: "secret") end.must_raise Rodauth::InternalRequestError error.message.must_equal ' (foo)' error.flash.must_be_nil error.reason.must_equal :foo error.field_errors.must_equal({}) end it "should handle direct calls to _handle_internal_request_error with just field error" do rodauth do enable :create_account, :internal_request before_create_account do set_field_error("foo", "bar") _handle_internal_request_error end end roda do |r| end error = proc do app.rodauth.create_account(login: "foo@example2.com", password: "secret") end.must_raise Rodauth::InternalRequestError error.message.must_match(/\A \(\{"foo" ?=> ?"bar"\}\)\z/) error.flash.must_be_nil error.reason.must_be_nil error.field_errors.must_equal({"foo"=>"bar"}) end it "should handle direct calls to _handle_internal_request_error with just flash" do rodauth do enable :create_account, :internal_request before_create_account do set_error_flash("foo") end end roda do |r| end error = proc do app.rodauth.create_account(login: "foo@example2.com", password: "secret") end.must_raise Rodauth::InternalRequestError error.message.must_equal 'foo' error.flash.must_equal 'foo' error.reason.must_be_nil error.field_errors.must_equal({}) end it "should allow checking whether an account exists using internal requests" do rodauth do enable :internal_request end roda do |r| end app.rodauth.account_exists?(:login=>'foo@example.com').must_equal true app.rodauth.account_exists?(:login=>'foo2@example.com').must_equal false proc do app.rodauth.account_exists?({}) end.must_raise Rodauth::InternalRequestError app.rodauth.account_id_for_login(:login=>'foo@example.com').must_equal DB[:accounts].get(:id) proc do app.rodauth.account_id_for_login(:login=>'foo2@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.account_id_for_login({}) end.must_raise Rodauth::InternalRequestError end it "should correctly handle features only loaded for internal requests" do rodauth do enable :login, :create_account, :internal_request internal_request_configuration do enable :disallow_common_passwords end end roda do |r| r.rodauth view :content=>"" end proc do app.rodauth.create_account(:login=>'foo@g.com', :password=>'0123456').must_be_nil end.must_raise Rodauth::InternalRequestError pass = 'sadf98023kwe0s' app.rodauth.create_account(:login=>'foo@g.com', :password=>pass).must_be_nil login(:login=>'foo@g.com', :pass=>pass) page.find('#notice_flash').text.must_equal 'You have been logged in' end it "should expose internal request methods only loaded in the internal request configuration" do rodauth do enable :login, :internal_request internal_request_configuration do enable :create_account end end roda do |r| r.rodauth view :content=>"" end pass = 'sadf98023kwe0s' app.rodauth.create_account(:login=>'foo@g.com', :password=>pass).must_be_nil login(:login=>'foo@g.com', :pass=>pass) page.find('#notice_flash').text.must_equal 'You have been logged in' end it "should have internal_request_eval internal request method" do rodauth do enable :login, :internal_request end roda do |r| r.rodauth view :content=>"" end id = DB[:accounts].get(:id) obj = Object.new obj2 = Object.new app.rodauth.internal_request_eval(:account_id=>id, :env=>{'x'=>obj2}) do [obj, session_value, request.env['x']] end.must_equal [obj, id, obj2] app.rodauth.internal_request_eval(:account_id=>id) do _return_from_internal_request(obj2) obj end.must_equal obj2 app.rodauth.internal_request_eval(:account_id=>id) do _set_internal_request_return_value(obj2) obj end.must_equal obj2 proc do app.rodauth.internal_request_eval(:account_id=>id) do set_error_flash('foo') obj end end.must_raise Rodauth::InternalRequestError end it "should be able to clear the session during an internal request" do rodauth do enable :logout, :internal_request end roda do |r| r.rodauth view :content=>"" end id = DB[:accounts].get(:id) app.rodauth.internal_request_eval(:account_id=>id) do logout logged_in? end.must_be_nil end it "should support internal_request_block when handling internal requests" do session = nil rodauth do enable :login, :internal_request after_login do session = session() set_session_value('check_type', internal_request_block.call) end end roda do |r| r.rodauth view :content=>"" end app.rodauth.valid_login_and_password?(:login=>'foo@example.com', :password=>'0123456789') do true end.must_equal true session['check_type'].must_equal true app.rodauth.valid_login_and_password?(:login=>'foo@example.com', :password=>'0123456789') do false end.must_equal true session['check_type'].must_equal false end it "should raise argument error when hmac_secret is missing but required" do rodauth do end roda do |r| rodauth.compute_hmac("secret") end error = proc{visit "/"}.must_raise(Rodauth::ConfigurationError) error.message.must_equal "hmac_secret not set" end it "should raise error when sequel database is missing" do begin database = Sequel::DATABASES.pop rodauth {} error = proc { roda {} }.must_raise(RuntimeError) error.message.must_equal "Sequel database connection is missing" ensure Sequel::DATABASES.push database if database end end it "should have internal_request feature work with Roda path_rewriter plugin" do @no_freeze = true rodauth do enable :login, :internal_request end roda do |r| self.class.rodauth.login({ :account_login => 'x', :password => 'y' }) rescue (next $!.message) end app.plugin :path_rewriter app.rewrite_path '/abc/', '/def/', :path_info => true visit '/' body.must_equal 'no account for login: "x"' end end jeremyevans-rodauth-b53f402/spec/session_expiration_spec.rb000066400000000000000000000103441515725514200242640ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth session expiration feature' do it "should expire sessions based on last activity and max lifetime checks" do inactivity = max_lifetime = 300 expiration_default = true rodauth do enable :login, :session_expiration session_expiration_default{expiration_default} session_inactivity_timeout{inactivity} max_session_lifetime{max_lifetime} end roda do |r| rodauth.check_session_expiration r.rodauth r.get("remove-creation"){session.delete(rodauth.session_created_session_key); r.redirect '/'} r.get("set-lastact"){session[rodauth.session_last_activity_session_key] = Time.now.to_i - 100000; r.redirect '/'} r.get("set-creation"){session[rodauth.session_created_session_key] = Time.now.to_i - 100000; r.redirect '/'} r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end visit '/' page.body.must_include "Not Logged" login page.body.must_include "Logged In" inactivity = -1 visit '/' page.title.must_equal 'Login' page.find('#error_flash').text.must_equal "This session has expired, please login again" login page.title.must_equal 'Login' page.find('#error_flash').text.must_equal "This session has expired, please login again" inactivity = 10 login max_lifetime = -1 visit '/' page.find('#error_flash').text.must_equal "This session has expired, please login again" login page.title.must_equal 'Login' page.find('#error_flash').text.must_equal "This session has expired, please login again" max_lifetime = 10 login page.body.must_include "Logged In" visit '/set-creation' page.title.must_equal 'Login' page.find('#error_flash').text.must_equal "This session has expired, please login again" login page.body.must_include "Logged In" visit '/remove-creation' page.title.must_equal 'Login' page.find('#error_flash').text.must_equal "This session has expired, please login again" expiration_default = false login page.body.must_include "Logged In" visit '/remove-creation' page.body.must_include "Logged In" end it "should expire sessions based on last activity and max lifetime checks when using jwt" do inactivity = max_lifetime = 300 expiration_default = true rodauth do enable :login, :logout, :session_expiration session_expiration_default{expiration_default} session_inactivity_timeout{inactivity} max_session_lifetime{max_lifetime} end roda(:jwt) do |r| rodauth.check_session_expiration r.rodauth r.post("set-creation"){rodauth.send(:set_session_value, rodauth.session_created_session_key, Time.now.to_i - 100000); [5]} r.post("remove-creation"){rodauth.send(:remove_session_value, rodauth.session_created_session_key); [4]} rodauth.logged_in? ? [1] : [2] end json_request.must_equal [200, [2]] json_login json_request.must_equal [200, [1]] inactivity = -1 json_request.must_equal [401, {'reason'=>'session_expired', 'error'=>"This session has expired, please login again"}] json_login json_request.must_equal [401, {'reason'=>'session_expired', 'error'=>"This session has expired, please login again"}] inactivity = 10 json_login max_lifetime = -1 json_request.must_equal [401, {'reason'=>'session_expired', 'error'=>"This session has expired, please login again"}] json_login json_request.must_equal [401, {'reason'=>'session_expired', 'error'=>"This session has expired, please login again"}] max_lifetime = 10 json_login json_request.must_equal [200, [1]] json_request('/set-creation').must_equal [200, [5]] json_request.must_equal [401, {'reason'=>'session_expired', 'error'=>"This session has expired, please login again"}] json_login json_request.must_equal [200, [1]] json_request('/remove-creation').must_equal [200, [4]] json_request.must_equal [401, {'reason'=>'session_expired', 'error'=>"This session has expired, please login again"}] expiration_default = false json_login json_request.must_equal [200, [1]] json_request('/remove-creation').must_equal [200, [4]] json_request.must_equal [200, [1]] end end jeremyevans-rodauth-b53f402/spec/single_session_spec.rb000066400000000000000000000160631515725514200233670ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth single session feature' do [true, false].each do |before| it "should limit accounts to a single logged in session, when loading single_session #{before ? "before" : "after"}" do secret = old_secret = nil allow_raw = true rodauth do features = [:logout, :single_session] features.reverse! if before enable :login, *features hmac_secret{secret} hmac_old_secret{old_secret} allow_raw_single_session_key?{allow_raw} end roda do |r| rodauth.check_single_session r.rodauth r.is("reset"){rodauth.reset_single_session_key; r.redirect '/'} r.is("clear"){session.delete(rodauth.single_session_session_key); DB[:account_session_keys].delete; r.redirect '/'} r.is("cleardb"){DB[:account_session_keys].delete; r.redirect '/'} r.is("clearsession"){session.delete(rodauth.single_session_session_key); r.redirect '/'} r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end visit '/reset' page.body.must_include "Not Logged" login page.body.must_include "Logged In" session1 = get_cookie('rack.session') logout visit '/' page.body.must_include "Not Logged" remove_cookie('rack.session') set_cookie('rack.session', session1) visit '/foo' page.current_path.must_equal '/' page.body.must_include "Not Logged" page.find('#error_flash').text.must_equal "This session has been logged out as another session has become active" login page.body.must_include "Logged In" session2 = get_cookie('rack.session') remove_cookie('rack.session') set_cookie('rack.session', session1) visit '/' page.body.must_include "Not Logged" page.find('#error_flash').text.must_equal "This session has been logged out as another session has become active" remove_cookie('rack.session') set_cookie('rack.session', session2) visit '/' page.body.must_include "Logged In" visit '/clear' page.current_path.must_equal '/' page.body.must_include "Logged In" secret = SecureRandom.random_bytes(32) visit '/' page.body.must_include "Logged In" allow_raw = false visit '/' page.body.must_include "Not Logged" login page.body.must_include "Logged In" visit '/clearsession' page.body.must_include "Logged In" visit '/cleardb' page.body.must_include "Logged In" login page.body.must_include "Logged In" visit '/reset' page.body.must_include "Not Logged" login page.body.must_include "Logged In" allow_raw = true secret = SecureRandom.random_bytes(32) visit '/' page.body.must_include "Not Logged" login page.body.must_include "Logged In" old_secret = secret secret = SecureRandom.random_bytes(32) visit '/' page.body.must_include "Logged In" old_secret = nil visit '/' page.body.must_include "Logged In" secret = SecureRandom.random_bytes(32) old_secret = SecureRandom.random_bytes(32) visit '/' page.body.must_include "Not Logged" end it "should remove single session keys when closing accounts, when loading single_session #{before ? "before" : "after"}" do rodauth do features = [:close_account, :single_session] features.reverse! if before enable :login, *features close_account_requires_password? false end roda do |r| rodauth.check_single_session r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login DB[:account_session_keys].count.must_equal 1 visit '/close-account' click_button 'Close Account' DB[:account_session_keys].count.must_equal 0 end end it "should clear single session when resetting password without a logged in session" do rodauth do enable :login, :reset_password, :single_session require_password_confirmation? false reset_password_autologin? false end roda do |r| r.rodauth rodauth.check_single_session r.root{view :content=>rodauth.logged_in? ? "Logged In!" : "Not Logged"} end login visit '/login' login(:pass=>'01234567', :visit=>false) click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" remove_cookie('rack.session') visit email_link(/(\/reset-password\?key=.+)$/) fill_in 'Password', :with=>'012345678911' DB[:account_session_keys].count.must_equal 1 click_button "Reset Password" page.find('#notice_flash').text.must_equal "Your password has been reset" page.body.must_include "Not Logged" DB[:account_session_keys].count.must_equal 0 end it "should not clear single session when changing login in a logged in session" do rodauth do enable :login, :change_login, :single_session require_login_confirmation? false change_login_requires_password? false end roda do |r| r.rodauth rodauth.check_single_session r.root{view :content=>rodauth.logged_in? ? "Logged In!" : "Not Logged"} end login visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' DB[:account_session_keys].count.must_equal 1 click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" DB[:account_session_keys].count.must_equal 1 visit '/' page.body.must_include "Logged In!" end it "should limit accounts to a single logged in session when using jwt" do rodauth do enable :login, :logout, :single_session end roda(:jwt) do |r| rodauth.check_single_session r.rodauth r.post("clear"){rodauth.session.delete(:single_session_key); DB[:account_session_keys].delete; [3]} rodauth.logged_in? ? [1] : [2] end json_login authorization1 = @authorization json_logout json_request.must_equal [200, [2]] @authorization = authorization1 json_request.must_equal [401, {'reason'=>'inactive_session', 'error'=>"This session has been logged out as another session has become active"}] json_login json_request.must_equal [200, [1]] authorization2 = @authorization @authorization = authorization1 json_request.must_equal [401, {'reason'=>'inactive_session', 'error'=>"This session has been logged out as another session has become active"}] @authorization = authorization2 json_request.must_equal [200, [1]] json_request('/clear').must_equal [200, [3]] json_request.must_equal [401, {'reason'=>'inactive_session', 'error'=>"This session has been logged out as another session has become active"}] json_request.must_equal [200, [2]] @authorization = authorization2 json_request.must_equal [401, {'reason'=>'inactive_session', 'error'=>"This session has been logged out as another session has become active"}] end end jeremyevans-rodauth-b53f402/spec/spec_helper.rb000066400000000000000000000327501515725514200216230ustar00rootroot00000000000000$: << 'lib' if RUBY_VERSION >= '3' begin require 'warning' rescue LoadError else Warning.ignore(%r{gems/mail-\d}) Warning.dedup if Warning.respond_to?(:dedup) end end if ENV.delete('COVERAGE') require 'simplecov' SimpleCov.start do enable_coverage :branch add_filter{|f| f.filename.match(%r{\A#{Regexp.escape(File.dirname(__FILE__))}/})} add_group('Missing'){|src| src.covered_percent < 100} add_group('Covered'){|src| src.covered_percent == 100} end end if ENV['SESSIONS'] == 'rack' || ENV['RODA_ROUTE_CSRF'] == 'no' gem 'rack', '< 3' end require 'capybara' require 'capybara/dsl' require 'rack/test' require 'stringio' require 'securerandom' ENV['MT_NO_PLUGINS'] = '1' # Work around stupid autoloading of plugins gem 'minitest' require 'minitest/global_expectations/autorun' require 'minitest/hooks/default' Capybara.exact = true if ENV['CHECK_METHOD_VISIBILITY'] require 'visibility_checker' VISIBILITY_CHANGES = [] Minitest.after_run do if VISIBILITY_CHANGES.empty? puts "No visibility changes" else puts "Visibility changes:" VISIBILITY_CHANGES.uniq!{|v,| v} puts(*VISIBILITY_CHANGES.map do |v, caller| "#{caller}: #{v.new_visibility} method #{v.overridden_by}##{v.method} overrides #{v.original_visibility} method in #{v.defined_in}" end.sort) end end end require 'roda' require 'sequel/core' require 'bcrypt' require 'mail' require 'tilt/string' require 'roda/plugins/render' unless db_url = ENV['RODAUTH_SPEC_DB'] db_url = if RUBY_ENGINE == 'jruby' 'jdbc:postgresql:///rodauth_test?user=rodauth_test&password=rodauth_test' else 'postgres:///?user=rodauth_test&password=rodauth_test' end end DB = Sequel.connect(db_url, :identifier_mangling=>false) DB.extension :freeze_datasets, :date_arithmetic puts "using #{DB.database_type}" if ENV['LOG_SQL'] require 'logger' DB.loggers << Logger.new($stdout) end if DB.adapter_scheme == :jdbc case DB.database_type when :postgres DB.add_named_conversion_proc(:citext){|s| s} DB.extension :pg_json # jsonb usage in audit_logging when :sqlite DB.timezone = :utc Sequel.application_timezone = :local end end if ENV['RODAUTH_SPEC_MIGRATE'] Sequel.extension :migration Sequel::Migrator.run(DB, 'spec/migrate_ci') end DB.freeze ENV['RACK_ENV'] = 'test' ::Mail.defaults do delivery_method :test end Base = Class.new(Roda) if ENV['LINT'] require 'rack/lint' Base.use Rack::Lint end Base.opts[:check_dynamic_arity] = Base.opts[:check_arity] = :warn Base.plugin :flash Base.plugin :render, :layout_opts=>{:path=>'spec/views/layout.str'} Base.plugin(:not_found){raise "path #{request.path_info} not found"} if defined?(Roda::RodaVersionNumber) && Roda::RodaVersionNumber >= 30100 if ENV['SESSIONS'] == 'middleware' require 'roda/session_middleware' Base.opts[:sessions_convert_symbols] = true Base.use RodaSessionMiddleware, :secret=>SecureRandom.random_bytes(64), :key=>'rack.session' elsif ENV['SESSIONS'] != 'rack' Base.plugin :sessions, :secret=>SecureRandom.random_bytes(64), :key=>'rack.session' end end if ENV['SESSIONS'] == 'rack' Base.use Rack::Session::Cookie, :secret => '0123456789' end unless defined?(Rack::Test::VERSION) && Rack::Test::VERSION >= '0.8' class Rack::Test::Cookie remove_method(:path) if method_defined?(:path) def path ([*(@options['path'] == "" ? "/" : @options['path'])].first.split(',').first || '/').strip end end end class Base attr_writer :title end JsonBase = Class.new(Roda) JsonBase.opts[:check_dynamic_arity] = JsonBase.opts[:check_arity] = :warn JsonBase.plugin(:not_found){raise "path #{request.path_info} not found"} if ENV['RODAUTH_PLAIN_HASH_RESPONSE_HEADERS'] == '1' Base.plugin :plain_hash_response_headers JsonBase.plugin :plain_hash_response_headers end RODAUTH_ALWAYS_ARGON2 = ENV['RODAUTH_ALWAYS_ARGON2'] == '1' require 'argon2' if RODAUTH_ALWAYS_ARGON2 PASSWORD_HASH_TABLE = ENV['RODAUTH_SEPARATE_SCHEMA'] ? Sequel[:rodauth_test_password][:account_password_hashes] : :account_password_hashes CONTENT_TYPE_KEY = Rack.release >= '3' ? 'content-type' : 'Content-Type' class Minitest::HooksSpec include Rack::Test::Methods include Capybara::DSL case ENV['RODA_ROUTE_CSRF'] when 'no' USE_ROUTE_CSRF = false when 'no-specific' USE_ROUTE_CSRF = true ROUTE_CSRF_OPTS = {:require_request_specific_tokens=>false, :check_header=>true} when 'always' USE_ROUTE_CSRF = :always ROUTE_CSRF_OPTS = {:check_header=>true} else USE_ROUTE_CSRF = true ROUTE_CSRF_OPTS = {:check_header=>true} end attr_reader :app def no_freeze! @no_freeze = true end def app=(app) @app = Capybara.app = app end def rodauth(&block) @rodauth_block = block end def rodauth_opts(type={}) opts = type.is_a?(Hash) ? type : {} if !USE_ROUTE_CSRF && !opts.has_key?(:csrf) opts[:csrf] = :rack_csrf end opts end def apply_csrf(app, opts) case opts[:csrf] when :rack_csrf app.plugin(:csrf, :raise => true, :skip_if=>lambda{|request| request.env["CONTENT_TYPE"] == "application/json"}) when false # nothing else app.plugin(:route_csrf, ROUTE_CSRF_OPTS) if USE_ROUTE_CSRF end end def roda(type=nil, &block) jwt_only = type == :jwt || type == :jwt_no_enable jwt = @jwt_type = type == :jwt || type == :jwt_html || type == :jwt_no_enable jwt_enable = type == :jwt || type == :jwt_html json_only = type == :json || type == :json_no_enable json = type == :json || type == :json_html || type == :json_no_enable json_enable = type == :json || type == :json_html app = Class.new(jwt_only ? JsonBase : Base) begin app.plugin :request_aref, :raise rescue LoadError end app.opts[:unsupported_block_result] = :raise app.opts[:unsupported_matcher] = :raise app.opts[:verbatim_string_matcher] = true rodauth_block = @rodauth_block opts = rodauth_opts(type) template_opts = {} template_opts[:freeze] = true if ENV['RODAUTH_TEMPLATE_FREEZE'] if Tilt::Template.method_defined?(:fixed_locals?) && defined?(Roda::RodaPlugins::Render::FIXED_LOCALS_COMPILED_METHOD_SUPPORT) template_opts[:default_fixed_locals] = '()' end app.plugin :render, :template_opts=>template_opts if json || jwt opts[:json] = jwt_only ? :only : true end if type == :no_csrf || (!USE_ROUTE_CSRF && (json || jwt)) opts[:csrf] = false end app.plugin(:rodauth, opts) do title_instance_variable :@title if jwt_enable enable :jwt jwt_secret '1' end if json_enable enable :json only_json? true if json_only end if jwt_enable || json_enable set_error_reason { |reason| json_response['reason'] = reason } end if ENV['RODAUTH_SEPARATE_SCHEMA'] password_hash_table PASSWORD_HASH_TABLE function_name do |name| "rodauth_test_password.#{name}" end end if RODAUTH_ALWAYS_ARGON2 enable :argon2 end instance_exec(&rodauth_block) end unless json_only || jwt_only apply_csrf(app, opts) end if USE_ROUTE_CSRF == :always && !json && opts[:csrf] != false orig_block = block block = proc do |r| unless env["CONTENT_TYPE"] == "application/json" || (jwt_enable && rodauth.use_jwt?) check_csrf! end instance_exec(r, &orig_block) end end app.route(&block) app.precompile_rodauth_templates unless @no_precompile || jwt_only app.freeze unless @no_freeze if ENV['CHECK_METHOD_VISIBILITY'] caller = caller_locations(1, 1)[0] app.opts[:rodauths].each_value do |c| VISIBILITY_CHANGES.concat(VisibilityChecker.visibility_changes(c).map{|v| [v, "#{caller.path}:#{caller.lineno}"]}) end end @app_opts = opts self.app = app end def email_link(regexp, to='foo@example.com') mail = email_sent(to) link = mail.body.to_s.gsub(/ $/, '')[regexp] link.must_be_kind_of(String) link end def email_sent(to='foo@example.com') msgs = Mail::TestMailer.deliveries msgs.length.must_equal 1 email = msgs.first email.to.first.must_equal to msgs.clear email end def remove_cookie(key) cookie_jar.delete(key) end def get_cookie(key) cookie_jar[key] end def retrieve_cookie(key) yield cookie_jar.get_cookie(key) if cookie_jar.respond_to?(:get_cookie) end def set_cookie(key, value) cookie_jar[key] = value end def cookie_jar page.driver.browser.rack_mock_session.cookie_jar end def get_csrf(env, *args) sc = Class.new(Base) apply_csrf(sc, @app_opts) csrf = nil sc.route do |_| csrf = csrf_token(*args) '' end method = env['REQUEST_METHOD'] env['REQUEST_METHOD'] = 'GET' if @cookie env["HTTP_COOKIE"] = @cookie.map { |k, v| "#{k}=#{v}" }.join("; ") end r = sc.call(env) env['REQUEST_METHOD'] = method if set_cookie = r[1]['Set-Cookie'] @cookie ||= {} set_cookie.split("\n").each do |cookie| cookie_key, cookie_value = cookie.split(';', 2)[0].split("=") if cookie.include?('expires=Thu, 01 Jan 1970 00:00:00') @cookie.delete(cookie_key) else @cookie[cookie_key] = cookie_value end end @cookie = nil if @cookie.empty? end csrf end def json_request(path='/', params={}) include_headers = params.delete(:include_headers) headers = params.delete(:headers) input = StringIO.new((params || {}).to_json) input.binmode env = {"REQUEST_METHOD" => params.delete(:method) || "POST", "HTTP_HOST" => "example.com", "PATH_INFO" => path, "SCRIPT_NAME" => "", "CONTENT_TYPE" => params.delete(:content_type) || "application/json", "SERVER_NAME" => 'example.com', "rack.input"=>input, "rack.errors"=>$stderr, "rack.url_scheme"=>"http" } if ENV['LINT'] env['SERVER_PROTOCOL'] ||= env['HTTP_VERSION'] || 'HTTP/1.0' env['HTTP_VERSION'] ||= env['SERVER_PROTOCOL'] env['QUERY_STRING'] ||= '' env['rack.input'] ||= rack_input env['rack.errors'] ||= StringIO.new env['rack.url_scheme'] ||= 'http' env['rack.version'] = [1, 5] if Rack.release < '2.3' env['SERVER_PORT'] ||= '80' env['rack.multiprocess'] = env['rack.multithread'] = env['rack.run_once'] = false end end if @authorization env["HTTP_AUTHORIZATION"] = "Bearer: #{@authorization}" end if @cookie env["HTTP_COOKIE"] = @cookie.map { |k, v| "#{k}=#{v}" }.join("; ") end env.merge!(headers) if headers r = @app.call(env) if set_cookie = header(r, 'Set-Cookie') @cookie ||= {} set_cookie = set_cookie.split("\n") if set_cookie.is_a?(String) set_cookie.each do |cookie| cookie_key, cookie_value = cookie.split(';', 2)[0].split("=") if cookie.include?('expires=Thu, 01 Jan 1970 00:00:00') @cookie.delete(cookie_key) else @cookie[cookie_key] = cookie_value end end @cookie = nil if @cookie.empty? end if authorization = header(r, 'Authorization') @authorization = authorization end body = String.new r[2].each{|s| body << s} r[2] = body if env["CONTENT_TYPE"] == "application/json" r[1][CONTENT_TYPE_KEY].must_equal 'application/json' r[2] = JSON.parse("[#{body}]").first end r.delete_at(1) unless include_headers r end if Rack.release >= '3' def header(res, key) res[1][key.downcase] end else def header(res, key) res[1][key] end end def json_login(opts={}) res = json_request(opts[:path]||'/login', :login=>opts[:login]||'foo@example.com', :password=>opts[:pass]||'0123456789') res.must_equal [200, {"success"=>'You have been logged in'}] unless opts[:no_check] res end def jwt_refresh_login(opts={}) res = json_login(opts.merge(:no_check => true)) jwt_refresh_validate_login(res) res end def jwt_refresh_validate_login(res) res.first.must_equal 200 res.last.keys.sort.must_equal ['access_token', 'refresh_token', 'success'] res.last['success'].must_equal 'You have been logged in' res end def jwt_refresh_validate(res) res.first.must_equal 200 res.last.keys.sort.must_equal ['access_token', 'refresh_token'] res end def json_logout json_request("/logout").must_equal [200, {"success"=>'You have been logged out'}] end def login(opts={}) visit(opts[:path]||'/login') unless opts[:visit] == false fill_in 'Login', :with=>opts[:login]||'foo@example.com' fill_in 'Password', :with=>opts[:pass]||'0123456789' click_button 'Login' end def logout visit '/logout' click_button 'Logout' end around do |&block| DB.transaction(:rollback=>:always, :savepoint=>true, :auto_savepoint=>true){super(&block)} end around(:all) do |&block| DB.transaction(:rollback=>:always) do hash = if RODAUTH_ALWAYS_ARGON2 ::Argon2::Password.new(t_cost: 1, m_cost: 5).create('0123456789') else BCrypt::Password.create('0123456789', :cost=>BCrypt::Engine::MIN_COST) end DB[PASSWORD_HASH_TABLE].insert(:id=>DB[:accounts].insert(:email=>'foo@example.com', :status_id=>2, :ph=>hash), :password_hash=>hash) super(&block) end end after do msgs = Mail::TestMailer.deliveries len = msgs.length msgs.clear len.must_equal 0 Capybara.reset_sessions! Capybara.use_default_driver end end jeremyevans-rodauth-b53f402/spec/sql/000077500000000000000000000000001515725514200175755ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/spec/sql/mssql_setup.sql000066400000000000000000000005001515725514200226700ustar00rootroot00000000000000CREATE LOGIN rodauth_test WITH PASSWORD = 'Rodauth1.'; CREATE LOGIN rodauth_test_password WITH PASSWORD = 'Rodauth1.'; CREATE DATABASE rodauth_test; GO USE rodauth_test; GO CREATE USER rodauth_test FOR LOGIN rodauth_test; GRANT CONNECT, EXECUTE TO rodauth_test; EXECUTE sp_changedbowner 'rodauth_test_password'; GO exit jeremyevans-rodauth-b53f402/spec/sql/mssql_teardown.sql000066400000000000000000000001371515725514200233610ustar00rootroot00000000000000DROP DATABASE rodauth_test; DROP LOGIN rodauth_test; DROP LOGIN rodauth_test_password; GO exit jeremyevans-rodauth-b53f402/spec/sql/mysql_setup.sql000066400000000000000000000004071515725514200227040ustar00rootroot00000000000000CREATE USER 'rodauth_test'@'localhost' IDENTIFIED BY 'rodauth_test'; CREATE USER 'rodauth_test_password'@'localhost' IDENTIFIED BY 'rodauth_test'; CREATE DATABASE rodauth_test; GRANT ALL ON rodauth_test.* TO 'rodauth_test_password'@'localhost' WITH GRANT OPTION; jeremyevans-rodauth-b53f402/spec/sql/mysql_teardown.sql000066400000000000000000000001611515725514200233640ustar00rootroot00000000000000DROP DATABASE rodauth_test; DROP USER 'rodauth_test'@'localhost'; DROP USER 'rodauth_test_password'@'localhost'; jeremyevans-rodauth-b53f402/spec/two_factor_spec.rb000066400000000000000000003164511515725514200225160ustar00rootroot00000000000000require_relative 'spec_helper' require 'rotp' describe 'Rodauth two factor feature' do secret_length = (ROTP::Base32.respond_to?(:random_base32) ? ROTP::Base32.random_base32 : ROTP::Base32.random).length def reset_otp_last_use DB[:account_otp_keys].update(:last_use=>Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, :seconds=>600)) end it "should allow two factor authentication setup, login, recovery, removal" do sms_phone = sms_message = nil hmac_secret = '123' hmac_old_secret = nil old_secret_used = false rodauth do enable :login, :logout, :otp, :recovery_codes, :sms_codes hmac_secret do hmac_secret end hmac_old_secret do hmac_old_secret end otp_valid_code_for_old_secret do raise if old_secret_used old_secret_used = true end sms_send do |phone, msg| proc{super(phone, msg)}.must_raise Rodauth::ConfigurationError sms_phone = phone sms_message = msg end sms_remove_failures do if super() == 1 sms[sms_failures_column].must_equal 0 sms.fetch(sms_code_column).must_be_nil end end auto_add_recovery_codes? true end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/otp-auth' unless rodauth.authenticated? view :content=>"With 2FA" else view :content=>"Without 2FA" end end login page.html.must_include('Without 2FA') %w'/otp-disable /recovery-auth /recovery-codes /sms-setup /sms-disable /sms-confirm /sms-request /sms-auth /otp-auth'.each do |path| visit path page.find('#error_flash').text.must_equal 'This account has not been setup for multifactor authentication' page.current_path.must_equal '/otp-setup' end page.title.must_equal 'Setup TOTP Authentication' page.html.must_include ''asdf' click_button 'Setup TOTP Authentication' page.find('#error_flash').text.must_equal 'Error setting up TOTP authentication' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>"asdf" click_button 'Setup TOTP Authentication' page.find('#error_flash').text.must_equal 'Error setting up TOTP authentication' page.html.must_include 'Invalid authentication code' hmac_secret = "321" fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#error_flash').text.must_equal 'Error setting up TOTP authentication' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With 2FA' logout login page.current_path.must_equal '/otp-auth' page.find_by_id('otp-auth-code')[:autocomplete].must_equal 'off' %w'/otp-disable /recovery-codes /otp-setup /sms-setup /sms-disable /sms-confirm'.each do |path| visit path page.find('#error_flash').text.must_equal 'You need to authenticate via an additional factor before continuing' page.current_path.must_equal '/multifactor-auth' end page.title.must_equal 'Authenticate Using Additional Factor' click_link 'Authenticate Using TOTP' page.title.must_equal 'Enter Authentication Code' fill_in 'Authentication Code', :with=>"asdf" click_button 'Authenticate Using TOTP' page.find('#error_flash').text.must_equal 'Error logging in via TOTP authentication' page.html.must_include 'Invalid authentication code' fill_in 'Authentication Code', :with=>"#{totp.now[0..2]} #{totp.now[3..-1]}" click_button 'Authenticate Using TOTP' page.find('#error_flash').text.must_equal 'Error logging in via TOTP authentication' page.html.must_include 'Invalid authentication code' reset_otp_last_use hmac_secret = '123' fill_in 'Authentication Code', :with=>"#{totp.now[0..2]} #{totp.now[3..-1]}" click_button 'Authenticate Using TOTP' page.find('#error_flash').text.must_equal 'Error logging in via TOTP authentication' page.html.must_include 'Invalid authentication code' reset_otp_last_use hmac_secret = '124' hmac_old_secret = '125' fill_in 'Authentication Code', :with=>"#{totp.now[0..2]} #{totp.now[3..-1]}" click_button 'Authenticate Using TOTP' page.find('#error_flash').text.must_equal 'Error logging in via TOTP authentication' page.html.must_include 'Invalid authentication code' reset_otp_last_use otp_auth_path = page.current_path visit otp_auth_path old_secret_used.must_equal false hmac_secret = '333' hmac_old_secret = '321' fill_in 'Authentication Code', :with=>"#{totp.now[0..2]} #{totp.now[3..-1]}" click_button 'Authenticate Using TOTP' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With 2FA' old_secret_used.must_equal true reset_otp_last_use logout login visit otp_auth_path hmac_secret = '321' hmac_old_secret = nil fill_in 'Authentication Code', :with=>"#{totp.now[0..2]} #{totp.now[3..-1]}" click_button 'Authenticate Using TOTP' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With 2FA' reset_otp_last_use visit '/otp-setup' page.find('#error_flash').text.must_equal 'You have already setup TOTP authentication' %w'/otp-auth /recovery-auth /sms-request /sms-auth'.each do |path| visit path page.find('#error_flash').text.must_equal 'You have already been multifactor authenticated' end visit '/sms-disable' page.find('#error_flash').text.must_equal 'SMS authentication has not been setup yet' visit '/sms-setup' page.title.must_equal 'Setup SMS Backup Number' fill_in 'Password', :with=>'012345678' fill_in 'Phone Number', :with=>'(123) 456' click_button 'Setup SMS Backup Number' page.find('#error_flash').text.must_equal 'Error setting up SMS authentication' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'Setup SMS Backup Number' page.find('#error_flash').text.must_equal 'Error setting up SMS authentication' page.html.must_include 'invalid SMS phone number' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication needs confirmation' sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS confirmation code for www\.example\.com is \d{12}\z/) page.title.must_equal 'Confirm SMS Backup Number' fill_in 'SMS Code', :with=>"asdf" click_button 'Confirm SMS Backup Number' page.find('#error_flash').text.must_equal 'Invalid or out of date SMS confirmation code used, must setup SMS authentication again' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' visit '/sms-setup' page.find('#error_flash').text.must_equal 'SMS authentication needs confirmation' page.title.must_equal 'Confirm SMS Backup Number' DB[:account_sms_codes].update(:code_issued_at=>Time.now - 310) sms_code = sms_message[/\d{12}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#error_flash').text.must_equal 'Invalid or out of date SMS confirmation code used, must setup SMS authentication again' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' sms_code = sms_message[/\d{12}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication has been setup' %w'/sms-setup /sms-confirm'.each do |path| visit path page.find('#error_flash').text.must_equal 'SMS authentication has already been setup' page.current_path.must_equal '/' end logout login visit '/sms-auth' page.current_path.must_equal '/sms-request' page.find('#error_flash').text.must_equal 'No current SMS code for this account' sms_phone = sms_message = nil page.title.must_equal 'Send SMS Code' click_button 'Send SMS Code' sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS authentication code for www\.example\.com is \d{6}\z/) sms_code = sms_message[/\d{6}\z/] fill_in 'SMS Code', :with=>"asdf" click_button 'Authenticate via SMS Code' page.html.must_include 'invalid SMS code' page.find('#error_flash').text.must_equal 'Error authenticating via SMS code' DB[:account_sms_codes].update(:code_issued_at=>Time.now - 310) fill_in 'SMS Code', :with=>sms_code click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'No current SMS code for this account' click_button 'Send SMS Code' sms_code = sms_message[/\d{6}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Authenticate via SMS Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' logout login visit '/sms-request' click_button 'Send SMS Code' 5.times do click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'Error authenticating via SMS code' page.current_path.must_equal '/sms-auth' end click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'SMS authentication has been locked out' page.current_path.must_equal '/multifactor-auth' visit '/sms-request' page.find('#error_flash').text.must_equal 'SMS authentication has been locked out' page.current_path.must_equal '/multifactor-auth' click_link 'Authenticate Using TOTP' fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP' visit '/sms-disable' page.title.must_equal 'Disable Backup SMS Authentication' fill_in 'Password', :with=>'012345678' click_button 'Disable Backup SMS Authentication' page.find('#error_flash').text.must_equal 'Error disabling SMS authentication' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'Disable Backup SMS Authentication' page.find('#notice_flash').text.must_equal 'SMS authentication has been disabled' page.current_path.must_equal '/' visit '/sms-setup' page.title.must_equal 'Setup SMS Backup Number' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' sms_code = sms_message[/\d{12}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' visit '/recovery-codes' page.title.must_equal 'View Authentication Recovery Codes' fill_in 'Password', :with=>'012345678' click_button 'View Authentication Recovery Codes' page.find('#error_flash').text.must_equal 'Unable to view recovery codes' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'View Authentication Recovery Codes' page.title.must_equal 'Authentication Recovery Codes' recovery_codes = find('#recovery-codes').text.split recovery_codes.length.must_equal 16 recovery_code = recovery_codes.first logout login 5.times do page.title.must_equal 'Enter Authentication Code' fill_in 'Authentication Code', :with=>"asdf" click_button 'Authenticate Using TOTP' page.find('#error_flash').text.must_equal 'Error logging in via TOTP authentication' page.html.must_include 'Invalid authentication code' end page.title.must_equal 'Enter Authentication Code' fill_in 'Authentication Code', :with=>"asdf" click_button 'Authenticate Using TOTP' page.find('#error_flash').text.must_equal 'TOTP authentication code use locked out due to numerous failures' click_link "Authenticate Using SMS Code" click_button 'Send SMS Code' 5.times do click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'Error authenticating via SMS code' end click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'SMS authentication has been locked out' page.title.must_equal 'Enter Authentication Recovery Code' fill_in 'Recovery Code', :with=>"asdf" click_button 'Authenticate via Recovery Code' page.find('#error_flash').text.must_equal 'Error authenticating via recovery code' page.html.must_include 'Invalid recovery code' fill_in 'Recovery Code', :with=>recovery_code click_button 'Authenticate via Recovery Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With 2FA' visit '/recovery-codes' fill_in 'Password', :with=>'0123456789' click_button 'View Authentication Recovery Codes' page.title.must_equal 'Authentication Recovery Codes' page.html.wont_include(recovery_code) find('#recovery-codes').text.split.length.must_equal 15 click_button 'Add Authentication Recovery Codes' page.find('#error_flash').text.must_equal 'Unable to add recovery codes' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'View Authentication Recovery Codes' find('#recovery-codes').text.split.length.must_equal 15 fill_in 'Password', :with=>'0123456789' click_button 'Add Authentication Recovery Codes' page.find('#notice_flash').text.must_equal 'Additional authentication recovery codes have been added' find('#recovery-codes').text.split.length.must_equal 16 page.html.wont_include('Add Additional Authentication Recovery Codes') visit '/otp-disable' fill_in 'Password', :with=>'012345678' click_button 'Disable TOTP Authentication' page.find('#error_flash').text.must_equal 'Error disabling TOTP authentication' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'Disable TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication has been disabled' page.html.must_include 'With 2FA' DB[:account_otp_keys].count.must_equal 0 end it "should allow namespaced two factor authentication without password requirements" do rodauth do enable :login, :logout, :otp, :recovery_codes two_factor_modifications_require_password? false otp_digits 8 prefix "/auth" end roda do |r| r.on "auth" do r.rodauth end r.redirect '/auth/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/auth/otp-auth' unless rodauth.two_factor_authenticated? view :content=>"With 2FA" else view :content=>"Without 2FA" end end login page.html.must_include('Without 2FA') %w'/auth/otp-disable /auth/recovery-auth /auth/recovery-codes /auth/otp-auth'.each do visit '/auth/otp-disable' page.find('#error_flash').text.must_equal 'This account has not been setup for multifactor authentication' page.current_path.must_equal '/auth/otp-setup' end page.title.must_equal 'Setup TOTP Authentication' page.html.must_include '8) fill_in 'Authentication Code', :with=>"asdf" click_button 'Setup TOTP Authentication' page.find('#error_flash').text.must_equal 'Error setting up TOTP authentication' page.html.must_include 'Invalid authentication code' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With 2FA' reset_otp_last_use visit '/auth/logout' click_button 'Logout' login(:visit=>false) page.current_path.must_equal '/auth/otp-auth' %w'/auth/otp-disable /auth/recovery-codes /auth/otp-setup'.each do |path| visit path page.find('#error_flash').text.must_equal 'You need to authenticate via an additional factor before continuing' page.current_path.must_equal '/auth/otp-auth' end page.title.must_equal 'Enter Authentication Code' fill_in 'Authentication Code', :with=>"asdf" click_button 'Authenticate Using TOTP' page.find('#error_flash').text.must_equal 'Error logging in via TOTP authentication' page.html.must_include 'Invalid authentication code' fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With 2FA' reset_otp_last_use visit '/auth/otp-auth' page.find('#error_flash').text.must_equal 'You have already been multifactor authenticated' visit '/auth/otp-setup' page.find('#error_flash').text.must_equal 'You have already setup TOTP authentication' visit '/auth/recovery-auth' page.find('#error_flash').text.must_equal 'You have already been multifactor authenticated' visit '/auth/recovery-codes' page.title.must_equal 'View Authentication Recovery Codes' click_button 'View Authentication Recovery Codes' page.title.must_equal 'Authentication Recovery Codes' find('#recovery-codes').text.split.length.must_equal 0 click_button 'Add Authentication Recovery Codes' page.find('#notice_flash').text.must_equal 'Additional authentication recovery codes have been added' recovery_codes = find('#recovery-codes').text.split recovery_codes.length.must_equal 16 recovery_code = recovery_codes.first page.html.wont_include('Add Additional Authentication Recovery Codes') visit '/auth/logout' click_button 'Logout' login(:visit=>false) 5.times do page.title.must_equal 'Enter Authentication Code' fill_in 'Authentication Code', :with=>"asdf" click_button 'Authenticate Using TOTP' page.find('#error_flash').text.must_equal 'Error logging in via TOTP authentication' page.html.must_include 'Invalid authentication code' end page.title.must_equal 'Enter Authentication Code' fill_in 'Authentication Code', :with=>"asdf" click_button 'Authenticate Using TOTP' page.find('#error_flash').text.must_equal 'TOTP authentication code use locked out due to numerous failures' page.title.must_equal 'Enter Authentication Recovery Code' fill_in 'Recovery Code', :with=>"asdf" click_button 'Authenticate via Recovery Code' page.find('#error_flash').text.must_equal 'Error authenticating via recovery code' page.html.must_include 'Invalid recovery code' fill_in 'Recovery Code', :with=>recovery_code click_button 'Authenticate via Recovery Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With 2FA' visit '/auth/recovery-codes' click_button 'View Authentication Recovery Codes' page.title.must_equal 'Authentication Recovery Codes' page.html.wont_include(recovery_code) find('#recovery-codes').text.split.length.must_equal 15 click_button 'Add Authentication Recovery Codes' page.find('#notice_flash').text.must_equal 'Additional authentication recovery codes have been added' find('#recovery-codes').text.split.length.must_equal 16 page.html.wont_include('Add Additional Authentication Recovery Codes') visit '/auth/otp-disable' click_button 'Disable TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication has been disabled' page.html.must_include 'With 2FA' DB[:account_otp_keys].count.must_equal 0 end it "should require login and OTP authentication to perform certain actions if user signed up for OTP" do rodauth do enable :login, :logout, :change_password, :change_login, :close_account, :otp end roda do |r| r.rodauth r.is "a" do rodauth.require_authentication view(:content=>"aaa") end view(:content=>"bbb") end %w'/change-password /change-login /close-account /a'.each do |path| visit '/change-password' page.current_path.must_equal '/login' end login %w'/change-password /change-login /close-account /a'.each do |path| visit path page.current_path.must_equal path end visit '/otp-setup' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.current_path.must_equal '/' logout login %w'/change-password /change-login /close-account /a'.each do |path| visit path page.current_path.must_equal '/otp-auth' end reset_otp_last_use fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'bbb' visit '/otp-disable' fill_in 'Password', :with=>'0123456789' click_button 'Disable TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication has been disabled' page.html.must_include 'bbb' visit 'a' page.html.must_include 'aaa' end it "should allow returning to requested location when two factor auth was required" do rodauth do enable :login, :logout, :otp, :jwt two_factor_auth_return_to_requested_location? true two_factor_auth_redirect "/" end roda do |r| r.rodauth r.root{view :content=>""} r.get('page') do rodauth.require_authentication view :content=>"Passed Authentication Required: #{r.params['foo']}" end end login visit '/otp-setup' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' logout reset_otp_last_use login visit '/page?foo=bar' page.current_path.must_equal '/otp-auth' fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include "Passed Authentication Required: bar" end it "should handle attempts to insert a duplicate recovery code" do keys = ['a', 'a', 'b'] interval = 1000000 rodauth do enable :login, :logout, :otp, :recovery_codes, :jwt otp_interval interval recovery_codes_limit 2 new_recovery_code{keys.shift} end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/otp-auth' unless rodauth.authenticated? view :content=>"With OTP" else view :content=>"Without OTP" end end login page.html.must_include('Without OTP') visit '/otp-auth' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret, :interval=>interval) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' page.current_path.must_equal '/' visit '/recovery-codes' fill_in 'Password', :with=>'0123456789' click_button 'View Authentication Recovery Codes' fill_in 'Password', :with=>'0123456789' click_button 'Add Authentication Recovery Codes' DB[:account_recovery_codes].select_order_map(:code).must_equal ['a', 'b'] end it "should handle two factor lockout when using rodauth.require_two_factor_setup and rodauth.require_authentication" do drift = nil rodauth do enable :login, :logout, :otp otp_drift do drift end end roda do |r| r.rodauth r.get('use2'){rodauth.uses_two_factor_authentication?.inspect} rodauth.require_authentication rodauth.require_two_factor_setup view :content=>"Logged in" end visit '/use2' page.body.must_equal 'false' login visit '/use2' page.body.must_equal 'false' visit '/otp-setup' page.title.must_equal 'Setup TOTP Authentication' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.at(Time.now - 60) click_button 'Setup TOTP Authentication' page.find('#error_flash').text.must_equal 'Error setting up TOTP authentication' drift = 30 fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'Logged in' reset_otp_last_use visit '/use2' page.body.must_equal 'true' logout login 6.times do page.title.must_equal 'Enter Authentication Code' fill_in 'Authentication Code', :with=>'foo' click_button 'Authenticate Using TOTP' end page.find('#error_flash').text.must_equal 'TOTP authentication code use locked out due to numerous failures' page.title.must_equal 'Authenticate Using Additional Factor' end it "should handle deleted account when checking rodauth.two_factor_authentication_setup?" do rodauth do enable :login, :logout, :two_factor_base account_password_hash_column :ph end roda do |r| r.rodauth r.get('setup'){rodauth.two_factor_authentication_setup?.inspect} "" end visit '/setup' page.body.must_equal 'false' login visit '/setup' page.body.must_equal 'false' DB[PASSWORD_HASH_TABLE].delete DB[:accounts].delete visit '/setup' page.body.must_equal 'false' end it "should allow two factor authentication setup, login, removal without recovery" do rodauth do enable :login, :logout, :otp otp_lockout_redirect '/' end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? if rodauth.otp_locked_out? view :content=>"OTP Locked Out" else r.redirect '/otp-auth' unless rodauth.authenticated? view :content=>"With OTP" end else view :content=>"Without OTP" end end visit '/recovery-auth' page.current_path.must_equal '/login' visit '/recovery-codes' page.current_path.must_equal '/login' login page.html.must_include('Without OTP') visit '/otp-setup' page.title.must_equal 'Setup TOTP Authentication' page.html.must_include ''0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With OTP' reset_otp_last_use logout login visit '/otp-auth' 6.times do page.title.must_equal 'Enter Authentication Code' fill_in 'Authentication Code', :with=>'foo' click_button 'Authenticate Using TOTP' end page.find('#error_flash').text.must_equal 'TOTP authentication code use locked out due to numerous failures' page.body.must_include 'OTP Locked Out' page.current_path.must_equal '/' DB[:account_otp_keys].update(:num_failures=>0) visit '/otp-auth' page.title.must_equal 'Enter Authentication Code' page.html.wont_include 'Authenticate using recovery code' fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With OTP' visit '/otp-disable' fill_in 'Password', :with=>'0123456789' click_button 'Disable TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication has been disabled' page.html.must_include 'Without OTP' DB[:account_otp_keys].count.must_equal 0 end [true, false].each do |before| it "should remove multifactor authentication information when closing accounts, when loading close_account #{before ? "before" : "after"}" do rodauth do features = [:otp, :recovery_codes, :sms_codes, :close_account] features.reverse! if before enable :login, :logout, *features two_factor_modifications_require_password? false close_account_requires_password? false sms_send{|*|} end roda do |r| r.rodauth r.root{view :content=>"With OTP"} end login visit '/otp-setup' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' visit '/sms-setup' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' visit '/recovery-codes' click_button 'View Authentication Recovery Codes' click_button 'Add Authentication Recovery Codes' DB[:account_otp_keys].count.must_equal 1 DB[:account_recovery_codes].count.must_equal 16 DB[:account_sms_codes].count.must_equal 1 visit '/close-account' click_button 'Close Account' [:account_otp_keys, :account_recovery_codes, :account_sms_codes].each do |t| DB[t].count.must_equal 0 end end end it "should have recovery_codes and sms_codes work when used without otp" do sms_code, sms_phone, sms_message = nil rodauth do enable :login, :logout, :recovery_codes, :sms_codes sms_send do |phone, msg| sms_phone = phone sms_message = msg sms_code = msg[/\d+\z/] end end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/sms-request' unless rodauth.authenticated? view :content=>"With OTP" else view :content=>"Without OTP" end end login page.html.must_include('Without OTP') %w'/recovery-auth /recovery-codes'.each do |path| visit path page.find('#error_flash').text.must_equal 'This account has not been setup for multifactor authentication' page.current_path.must_equal '/sms-setup' end %w'/sms-disable /sms-request /sms-auth'.each do |path| visit path page.find('#error_flash').text.must_equal 'SMS authentication has not been setup yet' page.current_path.must_equal '/sms-setup' end visit '/sms-setup' page.title.must_equal 'Setup SMS Backup Number' fill_in 'Password', :with=>'012345678' fill_in 'Phone Number', :with=>'(123) 456' click_button 'Setup SMS Backup Number' page.find('#error_flash').text.must_equal 'Error setting up SMS authentication' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'Setup SMS Backup Number' page.find('#error_flash').text.must_equal 'Error setting up SMS authentication' page.html.must_include 'invalid SMS phone number' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication needs confirmation' sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS confirmation code for www\.example\.com is \d{12}\z/) page.title.must_equal 'Confirm SMS Backup Number' fill_in 'SMS Code', :with=>"asdf" click_button 'Confirm SMS Backup Number' page.find('#error_flash').text.must_equal 'Invalid or out of date SMS confirmation code used, must setup SMS authentication again' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' visit '/sms-setup' page.find('#error_flash').text.must_equal 'SMS authentication needs confirmation' page.title.must_equal 'Confirm SMS Backup Number' DB[:account_sms_codes].update(:code_issued_at=>Time.now - 310) fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#error_flash').text.must_equal 'Invalid or out of date SMS confirmation code used, must setup SMS authentication again' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#notice_flash').text.must_equal "SMS authentication has been setup" visit '/recovery-codes' page.title.must_equal 'View Authentication Recovery Codes' fill_in 'Password', :with=>'012345678' click_button 'View Authentication Recovery Codes' page.find('#error_flash').text.must_equal 'Unable to view recovery codes' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'View Authentication Recovery Codes' page.title.must_equal 'Authentication Recovery Codes' recovery_codes = find('#recovery-codes').text.split recovery_codes.length.must_equal 0 recovery_code = recovery_codes.first fill_in 'Password', :with=>'0123456789' click_button 'Add Authentication Recovery Codes' page.title.must_equal 'Authentication Recovery Codes' recovery_codes = find('#recovery-codes').text.split recovery_codes.length.must_equal 16 recovery_code = recovery_codes.first logout login page.current_path.must_equal '/sms-request' %w'/recovery-codes /sms-setup /sms-disable /sms-confirm'.each do |path| visit path page.find('#error_flash').text.must_equal 'You need to authenticate via an additional factor before continuing' page.current_path.must_equal '/multifactor-auth' end visit '/sms-auth' page.current_path.must_equal '/sms-request' page.find('#error_flash').text.must_equal 'No current SMS code for this account' sms_phone = sms_message = nil page.title.must_equal 'Send SMS Code' click_button 'Send SMS Code' sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS authentication code for www\.example\.com is \d{6}\z/) fill_in 'SMS Code', :with=>"asdf" click_button 'Authenticate via SMS Code' page.html.must_include 'invalid SMS code' page.find('#error_flash').text.must_equal 'Error authenticating via SMS code' DB[:account_sms_codes].update(:code_issued_at=>Time.now - 310) fill_in 'SMS Code', :with=>sms_code click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'No current SMS code for this account' click_button 'Send SMS Code' fill_in 'SMS Code', :with=>sms_code click_button 'Authenticate via SMS Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' %w'/recovery-auth /sms-request /sms-auth'.each do |path| visit path page.find('#error_flash').text.must_equal 'You have already been multifactor authenticated' end %w'/sms-setup /sms-confirm'.each do |path| visit path page.find('#error_flash').text.must_equal 'SMS authentication has already been setup' page.current_path.must_equal '/' end logout login click_button 'Send SMS Code' 5.times do click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'Error authenticating via SMS code' page.current_path.must_equal '/sms-auth' end click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'SMS authentication has been locked out' page.current_path.must_equal '/recovery-auth' visit '/sms-request' page.find('#error_flash').text.must_equal 'SMS authentication has been locked out' page.current_path.must_equal '/recovery-auth' page.title.must_equal 'Enter Authentication Recovery Code' fill_in 'Recovery Code', :with=>"asdf" click_button 'Authenticate via Recovery Code' page.find('#error_flash').text.must_equal 'Error authenticating via recovery code' page.html.must_include 'Invalid recovery code' fill_in 'Recovery Code', :with=>recovery_code click_button 'Authenticate via Recovery Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With OTP' visit '/recovery-codes' fill_in 'Password', :with=>'0123456789' click_button 'View Authentication Recovery Codes' page.title.must_equal 'Authentication Recovery Codes' page.html.wont_include(recovery_code) find('#recovery-codes').text.split.length.must_equal 15 click_button 'Add Authentication Recovery Codes' page.find('#error_flash').text.must_equal 'Unable to add recovery codes' page.html.must_include 'invalid password' visit '/sms-disable' page.title.must_equal 'Disable Backup SMS Authentication' fill_in 'Password', :with=>'012345678' click_button 'Disable Backup SMS Authentication' page.find('#error_flash').text.must_equal 'Error disabling SMS authentication' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'Disable Backup SMS Authentication' page.find('#notice_flash').text.must_equal 'SMS authentication has been disabled' page.current_path.must_equal '/' DB[:account_sms_codes].count.must_equal 0 end it "should have recovery_codes work when used by itself" do rodauth do enable :login, :logout, :recovery_codes json_response_success_key nil end roda(:json_html) do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/recovery-auth' unless rodauth.authenticated? view :content=>"With OTP" else view :content=>"Without OTP" end end login page.html.must_include('Without OTP') visit '/recovery-auth' page.find('#error_flash').text.must_equal 'This account has not been setup for multifactor authentication' page.current_path.must_equal '/recovery-codes' page.title.must_equal 'View Authentication Recovery Codes' fill_in 'Password', :with=>'012345678' click_button 'View Authentication Recovery Codes' page.find('#error_flash').text.must_equal 'Unable to view recovery codes' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'View Authentication Recovery Codes' page.title.must_equal 'Authentication Recovery Codes' recovery_codes = find('#recovery-codes').text.split recovery_codes.length.must_equal 0 fill_in 'Password', :with=>'0123456789' click_button 'Add Authentication Recovery Codes' recovery_codes = find('#recovery-codes').text.split recovery_codes.length.must_equal 16 recovery_code = recovery_codes.shift logout login page.current_path.must_equal '/recovery-auth' visit '/recovery-codes' page.find('#error_flash').text.must_equal 'You need to authenticate via an additional factor before continuing' page.current_path.must_equal '/recovery-auth' page.title.must_equal 'Enter Authentication Recovery Code' fill_in 'Recovery Code', :with=>"asdf" click_button 'Authenticate via Recovery Code' page.find('#error_flash').text.must_equal 'Error authenticating via recovery code' page.html.must_include 'Invalid recovery code' fill_in 'Recovery Code', :with=>recovery_code click_button 'Authenticate via Recovery Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With OTP' visit '/recovery-codes' fill_in 'Password', :with=>'0123456789' click_button 'View Authentication Recovery Codes' page.title.must_equal 'Authentication Recovery Codes' page.html.wont_include(recovery_code) page.html.wont_include('Add Authentication Recovery Codes') recovery_codes = find('#recovery-codes').text.split recovery_codes.length.must_equal 16 recovery_code = recovery_codes.shift logout json_login(:no_check=>true) res = json_request('/recovery-auth', 'recovery-code'=>recovery_code) res.must_equal [200, {}] res = json_request('/recovery-codes', :password=>'0123456789') res[1].delete('codes').must_include recovery_codes.first res.must_equal [200, {}] end it "should have sms_codes work when used by itself" do sms_code, sms_phone, sms_message = nil rodauth do enable :login, :logout, :sms_codes sms_send do |phone, msg| sms_phone = phone sms_message = msg sms_code = msg[/\d+\z/] end end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? if rodauth.sms_locked_out? view :content=>"With SMS Locked Out" else rodauth.require_two_factor_authenticated view :content=>"With OTP" end else view :content=>"Without OTP" end end login page.html.must_include('Without OTP') %w'/sms-disable /sms-request /sms-auth'.each do |path| visit path page.find('#error_flash').text.must_equal 'SMS authentication has not been setup yet' page.current_path.must_equal '/sms-setup' end visit '/sms-setup' page.title.must_equal 'Setup SMS Backup Number' fill_in 'Password', :with=>'012345678' fill_in 'Phone Number', :with=>'(123) 456' click_button 'Setup SMS Backup Number' page.find('#error_flash').text.must_equal 'Error setting up SMS authentication' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'Setup SMS Backup Number' page.find('#error_flash').text.must_equal 'Error setting up SMS authentication' page.html.must_include 'invalid SMS phone number' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication needs confirmation' sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS confirmation code for www\.example\.com is \d{12}\z/) page.title.must_equal 'Confirm SMS Backup Number' fill_in 'SMS Code', :with=>"asdf" click_button 'Confirm SMS Backup Number' page.find('#error_flash').text.must_equal 'Invalid or out of date SMS confirmation code used, must setup SMS authentication again' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' visit '/sms-setup' page.find('#error_flash').text.must_equal 'SMS authentication needs confirmation' page.title.must_equal 'Confirm SMS Backup Number' DB[:account_sms_codes].update(:code_issued_at=>Time.now - 310) fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#error_flash').text.must_equal 'Invalid or out of date SMS confirmation code used, must setup SMS authentication again' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#notice_flash').text.must_equal "SMS authentication has been setup" logout login page.current_path.must_equal '/sms-request' %w'/sms-setup /sms-disable /sms-confirm'.each do |path| visit path page.find('#error_flash').text.must_equal 'You need to authenticate via an additional factor before continuing' page.current_path.must_equal '/sms-request' end visit '/sms-auth' page.current_path.must_equal '/sms-request' page.find('#error_flash').text.must_equal 'No current SMS code for this account' sms_phone = sms_message = nil page.title.must_equal 'Send SMS Code' click_button 'Send SMS Code' sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS authentication code for www\.example\.com is \d{6}\z/) fill_in 'SMS Code', :with=>"asdf" click_button 'Authenticate via SMS Code' page.html.must_include 'invalid SMS code' page.find('#error_flash').text.must_equal 'Error authenticating via SMS code' DB[:account_sms_codes].update(:code_issued_at=>Time.now - 310) fill_in 'SMS Code', :with=>sms_code click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'No current SMS code for this account' click_button 'Send SMS Code' fill_in 'SMS Code', :with=>sms_code click_button 'Authenticate via SMS Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' %w'/sms-request /sms-auth'.each do |path| visit path page.find('#error_flash').text.must_equal 'You have already been multifactor authenticated' end %w'/sms-setup /sms-confirm'.each do |path| visit path page.find('#error_flash').text.must_equal 'SMS authentication has already been setup' page.current_path.must_equal '/' end logout login click_button 'Send SMS Code' 5.times do click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'Error authenticating via SMS code' page.current_path.must_equal '/sms-auth' end click_button 'Authenticate via SMS Code' page.find('#error_flash').text.must_equal 'SMS authentication has been locked out' page.current_path.must_equal '/multifactor-auth' visit '/' page.body.must_include "With SMS Locked Out" visit '/sms-request' page.find('#error_flash').text.must_equal 'SMS authentication has been locked out' page.current_path.must_equal '/multifactor-auth' DB[:account_sms_codes].update(:num_failures=>0) visit '/sms-request' click_button 'Send SMS Code' fill_in 'SMS Code', :with=>sms_code click_button 'Authenticate via SMS Code' visit '/sms-disable' page.title.must_equal 'Disable Backup SMS Authentication' fill_in 'Password', :with=>'012345678' click_button 'Disable Backup SMS Authentication' page.find('#error_flash').text.must_equal 'Error disabling SMS authentication' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'Disable Backup SMS Authentication' page.find('#notice_flash').text.must_equal 'SMS authentication has been disabled' page.current_path.must_equal '/' DB[:account_sms_codes].count.must_equal 0 end [:jwt, :json].each do |json| it "should allow two factor authentication via #{json}" do hmac_secret = sms_phone = sms_message = sms_code = nil success_key = 'success' rodauth do enable :login, :logout, :otp, :recovery_codes, :sms_codes hmac_secret do hmac_secret end sms_send do |phone, msg| sms_phone = phone sms_message = msg sms_code = msg[/\d+\z/] end json_response_success_key do success_key end end roda(json) do |r| r.rodauth if rodauth.logged_in? if rodauth.two_factor_authentication_setup? if rodauth.authenticated? [1] else [2] end else [3] end else [4] end end json_request.must_equal [200, [4]] json_login json_request.must_equal [200, [3]] success_key = nil res = json_request('/multifactor-manage') res.must_equal [200, {'setup_links'=>%w'/otp-setup', 'remove_links'=>[]}] success_key = 'success' res = json_request('/multifactor-auth') res.must_equal [403, {"reason"=>"two_factor_not_setup", "error"=>"This account has not been setup for multifactor authentication"}] %w'/otp-disable /recovery-auth /recovery-codes /sms-setup /sms-confirm /otp-auth'.each do |path| json_request(path).must_equal [403, {'reason' => 'two_factor_not_setup', 'error'=>'This account has not been setup for multifactor authentication'}] end %w'/sms-disable /sms-request /sms-auth'.each do |path| json_request(path).must_equal [403, {'reason' => 'sms_not_setup', 'error'=>'SMS authentication has not been setup yet'}] end secret = (ROTP::Base32.respond_to?(:random_base32) ? ROTP::Base32.random_base32 : ROTP::Base32.random).downcase totp = ROTP::TOTP.new(secret) res = json_request('/otp-setup', :password=>'123456', :otp_secret=>secret) res.must_equal [401, {'reason'=>"invalid_password",'error'=>'Error setting up TOTP authentication', "field-error"=>["password", 'invalid password']}] res = json_request('/otp-setup', :password=>'0123456789', :otp=>'adsf', :otp_secret=>secret) res.must_equal [401, {'reason'=>"invalid_otp_auth_code",'error'=>'Error setting up TOTP authentication', "field-error"=>["otp", 'Invalid authentication code']}] res = json_request('/otp-setup', :password=>'0123456789', :otp=>'adsf', :otp_secret=>'asdf') res.must_equal [422, {'reason'=>"invalid_otp_secret",'error'=>'Error setting up TOTP authentication', "field-error"=>["otp_secret", 'invalid secret']}] res = json_request('/otp-setup', :password=>'0123456789', :otp=>totp.now, :otp_secret=>secret) res.must_equal [200, {'success'=>'TOTP authentication is now setup'}] reset_otp_last_use res = json_request('/multifactor-manage') res.must_equal [200, {'setup_links'=>%w'/sms-setup /recovery-codes', 'remove_links'=>%w'/otp-disable', "success"=>""}] json_logout json_login json_request.must_equal [200, [2]] res = json_request('/multifactor-manage') res.must_equal [401, {"reason"=>"two_factor_need_authentication", "error"=>"You need to authenticate via an additional factor before continuing"}] success_key = nil res = json_request('/multifactor-auth') res.must_equal [200, {'auth_links'=>%w'/otp-auth'}] success_key = 'success' %w'/otp-disable /recovery-codes /otp-setup /sms-setup /sms-disable /sms-confirm'.each do |path| json_request(path).must_equal [401, {'reason'=>'two_factor_need_authentication', 'error'=>'You need to authenticate via an additional factor before continuing'}] end res = json_request('/otp-auth', :otp=>'adsf') res.must_equal [401, {'reason'=>"invalid_otp_auth_code",'error'=>'Error logging in via TOTP authentication', "field-error"=>["otp", 'Invalid authentication code']}] res = json_request('/otp-auth', :otp=>totp.now) res.must_equal [200, {'success'=>'You have been multifactor authenticated'}] json_request.must_equal [200, [1]] reset_otp_last_use res = json_request('/otp-setup') res.must_equal [400, {'error'=>'You have already setup TOTP authentication'}] %w'/otp-auth /recovery-auth /sms-request /sms-auth'.each do |path| res = json_request(path) res.must_equal [403, {'reason'=>'two_factor_already_authenticated', 'error'=>'You have already been multifactor authenticated'}] end res = json_request('/sms-disable') res.must_equal [403, {'reason'=>'sms_not_setup', 'error'=>'SMS authentication has not been setup yet'}] res = json_request('/sms-setup', :password=>'012345678', "sms-phone"=>'(123) 456') res.must_equal [401, {'reason'=>"invalid_password",'error'=>'Error setting up SMS authentication', "field-error"=>["password", 'invalid password']}] res = json_request('/sms-setup', :password=>'0123456789', "sms-phone"=>'(123) 456') res.must_equal [422, {'reason'=>"invalid_phone_number",'error'=>'Error setting up SMS authentication', "field-error"=>["sms-phone", 'invalid SMS phone number']}] res = json_request('/sms-setup', :password=>'0123456789', "sms-phone"=>'(123) 4567 890') res.must_equal [200, {'success'=>'SMS authentication needs confirmation'}] sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS confirmation code for example\.com:? is \d{12}\z/) res = json_request('/sms-confirm', :sms_code=>'asdf') res.must_equal [401, {'reason'=>'invalid_sms_confirmation_code', 'error'=>'Invalid or out of date SMS confirmation code used, must setup SMS authentication again'}] res = json_request('/sms-setup', :password=>'0123456789', "sms-phone"=>'(123) 4567 890') res.must_equal [200, {'success'=>'SMS authentication needs confirmation'}] DB[:account_sms_codes].update(:code_issued_at=>Time.now - 310) res = json_request('/sms-confirm', :sms_code=>sms_code) res.must_equal [401, {'reason'=>'invalid_sms_confirmation_code', 'error'=>'Invalid or out of date SMS confirmation code used, must setup SMS authentication again'}] res = json_request('/sms-setup', :password=>'0123456789', "sms-phone"=>'(123) 4567 890') res.must_equal [200, {'success'=>'SMS authentication needs confirmation'}] res = json_request('/sms-confirm', "sms-code"=>sms_code) res.must_equal [200, {'success'=>'SMS authentication has been setup'}] %w'/sms-setup /sms-confirm'.each do |path| res = json_request(path) res.must_equal [403, {'reason'=>'sms_already_setup', 'error'=>'SMS authentication has already been setup'}] end res = json_request('/multifactor-manage') res.must_equal [200, {'setup_links'=>%w'/recovery-codes', 'remove_links'=>%w'/otp-disable /sms-disable', "success"=>""}] res = json_request('/multifactor-auth') res.must_equal [403, {"reason"=>"two_factor_already_authenticated", "error"=>"You have already been multifactor authenticated"}] json_logout json_login res = json_request('/multifactor-auth') res.must_equal [200, {'auth_links'=>%w'/otp-auth /sms-request', "success"=>""}] res = json_request('/sms-auth') res.must_equal [401, {'reason'=>"no_current_sms_code", 'error'=>'No current SMS code for this account'}] sms_phone = sms_message = nil res = json_request('/sms-request') res.must_equal [200, {'success'=>'SMS authentication code has been sent'}] sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS authentication code for example\.com:? is \d{6}\z/) res = json_request('/sms-auth') res.must_equal [401, {'reason'=>"invalid_sms_code", 'error'=>'Error authenticating via SMS code', "field-error"=>["sms-code", "invalid SMS code"]}] DB[:account_sms_codes].update(:code_issued_at=>Time.now - 310) res = json_request('/sms-auth') res.must_equal [401, {'reason'=>"no_current_sms_code", 'error'=>'No current SMS code for this account'}] res = json_request('/sms-request') res.must_equal [200, {'success'=>'SMS authentication code has been sent'}] res = json_request('/sms-auth', 'sms-code'=>sms_code) res.must_equal [200, {'success'=>'You have been multifactor authenticated'}] json_request.must_equal [200, [1]] json_logout json_login res = json_request('/sms-request') res.must_equal [200, {'success'=>'SMS authentication code has been sent'}] 5.times do res = json_request('/sms-auth') res.must_equal [401, {'reason'=>"invalid_sms_code", 'error'=>'Error authenticating via SMS code', "field-error"=>["sms-code", "invalid SMS code"]}] end res = json_request('/sms-auth') res.must_equal [403, {'reason'=>'sms_locked_out', 'error'=>'SMS authentication has been locked out'}] res = json_request('/sms-request') res.must_equal [403, {'reason'=>'sms_locked_out', 'error'=>'SMS authentication has been locked out'}] res = json_request('/otp-auth', :otp=>totp.now) res.must_equal [200, {'success'=>'You have been multifactor authenticated'}] json_request.must_equal [200, [1]] res = json_request('/sms-disable', :password=>'012345678') res.must_equal [401, {'reason'=>"invalid_password", 'error'=>'Error disabling SMS authentication', "field-error"=>["password", 'invalid password']}] res = json_request('/sms-disable', :password=>'0123456789') res.must_equal [200, {'success'=>'SMS authentication has been disabled'}] res = json_request('/sms-setup', :password=>'0123456789', "sms-phone"=>'(123) 4567 890') res.must_equal [200, {'success'=>'SMS authentication needs confirmation'}] res = json_request('/sms-confirm', "sms-code"=>sms_code) res.must_equal [200, {'success'=>'SMS authentication has been setup'}] res = json_request('/recovery-codes', :password=>'asdf') res.must_equal [401, {'reason'=>"invalid_password", 'error'=>'Unable to view recovery codes', "field-error"=>["password", 'invalid password']}] res = json_request('/recovery-codes', :password=>'0123456789') res[1].delete('codes').must_be_empty res.must_equal [200, {'success'=>''}] res = json_request('/recovery-codes', :password=>'0123456789', :add=>'1') codes = res[1].delete('codes') codes.sort.must_equal DB[:account_recovery_codes].select_map(:code).sort codes.length.must_equal 16 res.must_equal [200, {'success'=>'Additional authentication recovery codes have been added'}] json_logout json_login 5.times do res = json_request('/otp-auth', :otp=>'asdf') res.must_equal [401, {'reason'=>"invalid_otp_auth_code", 'error'=>'Error logging in via TOTP authentication', "field-error"=>["otp", 'Invalid authentication code']}] end res = json_request('/otp-auth', :otp=>'asdf') res.must_equal [403, {'reason'=>"otp_locked_out",'error'=>'TOTP authentication code use locked out due to numerous failures'}] res = json_request('/sms-request') 5.times do res = json_request('/sms-auth') res.must_equal [401, {'reason'=>"invalid_sms_code", 'error'=>'Error authenticating via SMS code', "field-error"=>["sms-code", "invalid SMS code"]}] end res = json_request('/otp-auth', :otp=>'asdf') res.must_equal [403, {'reason'=>"otp_locked_out", 'error'=>'TOTP authentication code use locked out due to numerous failures'}] res = json_request('/sms-auth') res.must_equal [403, {'reason'=>'sms_locked_out', 'error'=>'SMS authentication has been locked out'}] res = json_request('/recovery-auth', 'recovery-code'=>'adsf') res.must_equal [401, {'reason'=>"invalid_recovery_code", 'error'=>'Error authenticating via recovery code', "field-error"=>["recovery-code", "Invalid recovery code"]}] res = json_request('/recovery-auth', 'recovery-code'=>codes.first) res.must_equal [200, {'success'=>'You have been multifactor authenticated'}] json_request.must_equal [200, [1]] res = json_request('/recovery-codes', :password=>'0123456789') codes2 = res[1].delete('codes') codes2.sort.must_equal codes[1..-1].sort res.must_equal [200, {'success'=>''}] res = json_request('/recovery-codes', :password=>'012345678', :add=>'1') res.must_equal [401, {'reason'=>"invalid_password", 'error'=>'Unable to add recovery codes', "field-error"=>["password", 'invalid password']}] res = json_request('/recovery-codes', :password=>'0123456789', :add=>'1') codes3 = res[1].delete('codes') (codes3 - codes2).length.must_equal 1 res.must_equal [200, {'success'=>'Additional authentication recovery codes have been added'}] res = json_request('/otp-disable', :password=>'012345678') res.must_equal [401, {'reason'=>"invalid_password", 'error'=>'Error disabling TOTP authentication', "field-error"=>["password", 'invalid password']}] res = json_request('/otp-disable', :password=>'0123456789') res.must_equal [200, {'success'=>'TOTP authentication has been disabled'}] DB[:account_otp_keys].count.must_equal 0 hmac_secret = "123" res = json_request('/otp-setup') secret = res[1].delete("otp_secret") raw_secret = res[1].delete("otp_raw_secret") res.must_equal [422, {'reason'=>"invalid_otp_secret",'error'=>'Error setting up TOTP authentication', "field-error"=>["otp_secret", 'invalid secret']}] totp = ROTP::TOTP.new(secret) hmac_secret = "321" res = json_request('/otp-setup', :password=>'0123456789', :otp=>totp.now, :otp_secret=>secret, :otp_raw_secret=>raw_secret) res.must_equal [422, {'reason'=>"invalid_otp_secret",'error'=>'Error setting up TOTP authentication', "field-error"=>["otp_secret", 'invalid secret']}] reset_otp_last_use hmac_secret = "123" res = json_request('/otp-setup', :password=>'0123456789', :otp=>totp.now, :otp_secret=>secret, :otp_raw_secret=>raw_secret) res.must_equal [200, {'success'=>'TOTP authentication is now setup'}] reset_otp_last_use json_logout json_login hmac_secret = "321" res = json_request('/otp-auth', :otp=>totp.now) res.must_equal [401, {'reason'=>"invalid_otp_auth_code",'error'=>'Error logging in via TOTP authentication', "field-error"=>["otp", 'Invalid authentication code']}] hmac_secret = "123" res = json_request('/otp-auth', :otp=>totp.now) res.must_equal [200, {'success'=>'You have been multifactor authenticated'}] json_request.must_equal [200, [1]] end end it "should call the two factor auth before hook only when setup" do before_called = false rodauth do enable :login, :otp, :logout before_otp_auth_route{before_called = true} before_otp_setup_route{otp} end roda do |r| r.rodauth r.get 'valid', String do |code| rodauth.otp_valid_code?(code).to_s end r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/otp-auth' unless rodauth.authenticated? view :content=>"With OTP" else view :content=>"Without OTP" end end visit '/valid/foo' page.body.must_equal 'false' login page.html.must_include('Without OTP') visit '/otp-auth' before_called.must_equal false page.current_path.must_equal '/otp-setup' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With OTP' logout before_called.must_equal false login page.current_path.must_equal '/otp-auth' before_called.must_equal true end it "should allow for timing out otp authentication using otp_last_use" do rodauth do enable :login, :otp, :logout end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? if rodauth.authenticated_by && rodauth.authenticated_by.include?('totp') && rodauth.otp_last_use < Time.now - 3600 rodauth.authenticated_by.delete('totp') end rodauth.require_authentication view :content=>"With OTP" else view :content=>"Without OTP" end end login page.html.must_include('Without OTP') visit '/otp-auth' page.current_path.must_equal '/otp-setup' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With OTP' reset_otp_last_use visit '/' page.html.must_include 'With OTP' page.current_path.must_equal '/' DB[:account_otp_keys].update(:last_use=>Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, :seconds=>4600)) visit '/' page.current_path.must_equal '/otp-auth' end it "should show as user is authenticated when setting up OTP" do no_freeze! rodauth do enable :login, :otp hmac_secret '123' end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? r.redirect '/otp-setup' unless rodauth.two_factor_authentication_setup? view :content=>"With OTP" end @app.plugin :render, :layout_opts=>{:path=>'spec/views/layout-auth-check.str'} login page.title.must_equal 'Setup TOTP Authentication' page.html.must_include 'Is Logged In' page.html.must_include 'Is Authenticated' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With OTP' end it "should not display links for routes that were disabled" do otp_auth_route = 'otp-auth' otp_setup_route = 'otp-setup' otp_disable_route = 'otp-disable' recovery_auth_route = 'recovery-auth' recovery_codes_route = 'recovery-codes' sms_request_route = 'sms-request' sms_setup_route = 'sms-setup' sms_disable_route = 'sms-disable' sms_message = nil rodauth do enable :login, :logout, :otp, :recovery_codes, :sms_codes auto_add_recovery_codes? true sms_send { |phone, msg| sms_message = msg } otp_auth_route { otp_auth_route } otp_setup_route { otp_setup_route } otp_disable_route { otp_disable_route } recovery_auth_route { recovery_auth_route } recovery_codes_route { recovery_codes_route } sms_request_route { sms_request_route } sms_setup_route { sms_setup_route } sms_disable_route { sms_disable_route } end roda do |r| r.rodauth r.get('auth-links') { rodauth.two_factor_auth_links.map { |link| link[1] }.to_s } r.get('setup-links') { rodauth.two_factor_setup_links.map { |link| link[1] }.to_s } r.get('remove-links') { rodauth.two_factor_remove_links.map { |link| link[1] }.to_s } r.root{view :content=>"Home"} end visit '/login' fill_in 'Login', :with=>"foo@example.com" fill_in 'Password', :with=>"0123456789" click_on 'Login' page.find('#notice_flash').text.must_equal "You have been logged in" otp_setup_route = nil visit '/setup-links' page.html.must_equal '[]' otp_setup_route = 'otp-setup' visit '/multifactor-auth' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Password', :with=>'0123456789' fill_in 'Authentication Code', :with=>totp.now click_on 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' recovery_codes_route = nil sms_setup_route = nil visit '/setup-links' page.html.must_equal '[]' recovery_codes_route = 'recovery-codes' sms_setup_route = 'sms-setup' visit '/setup-links' page.html.must_equal '["/sms-setup", "/recovery-codes"]' visit '/sms-setup' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication needs confirmation' sms_code = sms_message[/\d{12}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication has been setup' visit '/auth-links' page.html.must_equal '["/otp-auth", "/sms-request", "/recovery-auth"]' otp_auth_route = nil recovery_auth_route = nil sms_request_route = nil visit '/auth-links' page.html.must_equal '[]' visit '/remove-links' page.html.must_equal '["/otp-disable", "/sms-disable"]' otp_disable_route = nil sms_disable_route = nil visit '/remove-links' page.html.must_equal '[]' end it "should allow using otp via internal requests" do rodauth do enable :login, :logout, :otp, :internal_request hmac_secret '123' domain 'example.com' end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? r.redirect '/otp-setup' unless rodauth.two_factor_authentication_setup? r.redirect '/otp-auth' unless rodauth.two_factor_authenticated? view :content=>"" end otp_hash = app.rodauth.otp_setup_params(:account_login=>'foo@example.com') otp_hash.length.must_equal 2 secret, raw_secret = otp_hash.values_at(:otp_setup, :otp_setup_raw) totp = ROTP::TOTP.new(secret) proc do app.rodauth.otp_setup(:account_login=>'foo@example.com', :otp_setup=>secret[0...-1], :otp_setup_raw=>raw_secret, :otp_auth=>totp.now) end.must_raise Rodauth::InternalRequestError proc do app.rodauth.otp_setup(:account_login=>'foo@example.com', :otp_setup=>secret, :otp_setup_raw=>raw_secret[0...-1], :otp_auth=>totp.now) end.must_raise Rodauth::InternalRequestError proc do app.rodauth.otp_setup(:account_login=>'foo@example.com', :otp_setup=>secret, :otp_setup_raw=>raw_secret, :otp_auth=>totp.now[0...-1]) end.must_raise Rodauth::InternalRequestError app.rodauth.otp_setup(:account_login=>'foo@example.com', :otp_setup=>secret, :otp_setup_raw=>raw_secret, :otp_auth=>totp.now).must_be_nil reset_otp_last_use proc do app.rodauth.otp_setup_params(:account_login=>'foo@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.otp_setup(:account_login=>'foo@example.com', :otp_setup=>secret, :otp_setup_raw=>raw_secret, :otp_auth=>totp.now) end.must_raise Rodauth::InternalRequestError login fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' reset_otp_last_use proc do app.rodauth.otp_auth(:account_login=>'foo@example.com', :otp_auth=>totp.now[0...-1]) end.must_raise Rodauth::InternalRequestError app.rodauth.otp_auth(:account_login=>'foo@example.com', :otp_auth=>totp.now).must_be_nil reset_otp_last_use app.rodauth.valid_otp_auth?(:account_login=>'foo@example.com', :otp_auth=>totp.now[0...-1]).must_equal false reset_otp_last_use app.rodauth.valid_otp_auth?(:account_login=>'foo@example.com', :otp_auth=>totp.now).must_equal true reset_otp_last_use app.rodauth.otp_disable(:account_login=>'foo@example.com').must_be_nil app.rodauth.valid_otp_auth?(:account_login=>'foo@example.com', :otp_auth=>totp.now[0...-1]).must_equal false proc do app.rodauth.otp_disable(:account_login=>'foo@example.com') end.must_raise Rodauth::InternalRequestError end it "should allow using otp via internal requests without hmac" do rodauth do enable :login, :logout, :otp, :internal_request domain 'example.com' end roda do |r| end otp_hash = app.rodauth.otp_setup_params(:account_login=>'foo@example.com') otp_hash.length.must_equal 1 secret = otp_hash[:otp_setup] totp = ROTP::TOTP.new(secret) proc do app.rodauth.otp_setup(:account_login=>'foo@example.com', :otp_setup=>secret[0...-1], :otp_auth=>totp.now) end.must_raise Rodauth::InternalRequestError proc do app.rodauth.otp_setup(:account_login=>'foo@example.com', :otp_setup=>secret, :otp_auth=>totp.now[0...-1]) end.must_raise Rodauth::InternalRequestError app.rodauth.otp_setup(:account_login=>'foo@example.com', :otp_setup=>secret, :otp_auth=>totp.now).must_be_nil reset_otp_last_use app.rodauth.valid_otp_auth?(:account_login=>'foo@example.com', :otp_auth=>totp.now).must_equal true reset_otp_last_use end it "should allow using recovery codes via internal requests" do rodauth do enable :login, :logout, :recovery_codes, :internal_request recovery_codes_primary? false end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? rodauth.require_two_factor_authenticated view :content=>"" end app.rodauth.recovery_codes(:account_login=>'foo@example.com').must_equal [] recovery_codes = app.rodauth.recovery_codes(:account_login=>'foo@example.com', :add_recovery_codes=>'1') recovery_codes.length.must_equal 16 proc do app.rodauth.recovery_auth(:account_login=>'foo@example.com', :recovery_codes=>'foo') end.must_raise Rodauth::InternalRequestError app.rodauth.recovery_auth(:account_login=>'foo@example.com', :recovery_codes=>recovery_codes.shift).must_be_nil app.rodauth.valid_recovery_auth?(:account_login=>'foo@example.com', :recovery_codes=>'foo').must_equal false app.rodauth.valid_recovery_auth?(:account_login=>'foo@example.com', :recovery_codes=>recovery_codes.shift).must_equal true login fill_in 'Recovery Code', :with=>recovery_codes.shift click_button 'Authenticate via Recovery Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' recovery_codes2 = app.rodauth.recovery_codes(:account_login=>'foo@example.com') recovery_codes2.sort.must_equal recovery_codes.sort recovery_codes3 = app.rodauth.recovery_codes(:account_login=>'foo@example.com', :add_recovery_codes=>'1') recovery_codes3.length.must_equal 16 (recovery_codes & recovery_codes3).length.must_equal 13 end it "should allow using sms codes via internal requests" do sms_message = nil rodauth do enable :login, :logout, :sms_codes, :internal_request sms_send do |phone, msg| sms_message = msg end domain 'example.com' end roda do |r| r.rodauth rodauth.require_two_factor_authenticated view :content=>"" end app.rodauth.sms_setup(:account_login=>'foo@example.com', :sms_phone=>'1112223333').must_be_nil sms_message.must_match(/\ASMS confirmation code for example\.com is \d{12}\z/) sms_code = sms_message[/\d{12}\z/] login fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication has been setup' logout proc do app.rodauth.sms_setup(:account_login=>'foo@example.com', :sms_phone=>'1112224444') end.must_raise Rodauth::InternalRequestError login page.title.must_equal 'Send SMS Code' click_button 'Send SMS Code' sms_message.must_match(/\ASMS authentication code for example\.com is \d{6}\z/) sms_code = sms_message[/\d{6}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Authenticate via SMS Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' logout app.rodauth.sms_disable(:account_login=>'foo@example.com').must_be_nil proc do app.rodauth.sms_disable(:account_login=>'foo@example.com') end.must_raise Rodauth::InternalRequestError login fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication needs confirmation' sms_message.must_match(/\ASMS confirmation code for example\.com is \d{12}\z/) sms_code = sms_message[/\d{12}\z/] logout app.rodauth.sms_confirm(:account_login=>'foo@example.com', :sms_code=>sms_code).must_be_nil proc do app.rodauth.sms_confirm(:account_login=>'foo@example.com', :sms_code=>sms_code) end.must_raise Rodauth::InternalRequestError app.rodauth.sms_request(:account_login=>'foo@example.com').must_be_nil sms_message.must_match(/\ASMS authentication code for example\.com is \d{6}\z/) sms_code = sms_message[/\d{6}\z/] app.rodauth.sms_auth(:account_login=>'foo@example.com', :sms_code=>sms_code).must_be_nil login page.title.must_equal 'Send SMS Code' click_button 'Send SMS Code' sms_message.must_match(/\ASMS authentication code for example\.com is \d{6}\z/) sms_code = sms_message[/\d{6}\z/] logout app.rodauth.valid_sms_auth?(:account_login=>'foo@example.com', :sms_code=>sms_code).must_equal true app.rodauth.valid_sms_auth?(:account_login=>'foo@example.com', :sms_code=>sms_code).must_equal false end it "should allow removing all multifactor authentication via internal requests" do sms_message = nil rodauth do enable :otp, :sms_codes, :recovery_codes, :internal_request sms_send do |phone, msg| sms_message = msg end domain 'example.com' end roda do |r| end otp_hash = app.rodauth.otp_setup_params(:account_login=>'foo@example.com') totp = ROTP::TOTP.new(otp_hash[:otp_setup]) app.rodauth.otp_setup(otp_hash.merge(:account_login=>'foo@example.com', :otp_auth=>totp.now)).must_be_nil app.rodauth.sms_setup(:account_login=>'foo@example.com', :sms_phone=>'1112223333').must_be_nil sms_message.must_match(/\ASMS confirmation code for example\.com is \d{12}\z/) sms_code = sms_message[/\d{12}\z/] app.rodauth.sms_confirm(:account_login=>'foo@example.com', :sms_code=>sms_code).must_be_nil app.rodauth.sms_request(:account_login=>'foo@example.com').must_be_nil sms_message.must_match(/\ASMS authentication code for example\.com is \d{6}\z/) sms_code = sms_message[/\d{6}\z/] app.rodauth.sms_auth(:account_login=>'foo@example.com', :sms_code=>sms_code).must_be_nil recovery_codes = app.rodauth.recovery_codes(:account_login=>'foo@example.com', :add_recovery_codes=>'1') app.rodauth.recovery_auth(:account_login=>'foo@example.com', :recovery_codes=>recovery_codes.shift).must_be_nil app.rodauth.two_factor_disable(:account_login=>'foo@example.com').must_be_nil [:account_otp_keys, :account_recovery_codes, :account_sms_codes].each do |t| DB[t].count.must_equal 0 end end it "should prevent authentication when logged in via password and MFA was disabled in another session" do rodauth do enable :login, :otp, :internal_request domain 'example.com' end roda do |r| r.rodauth rodauth.require_authentication r.root { "" } end otp_hash = app.rodauth.otp_setup_params(:account_login=>'foo@example.com') totp = ROTP::TOTP.new(otp_hash[:otp_setup]) app.rodauth.otp_setup(otp_hash.merge(:account_login=>'foo@example.com', :otp_auth=>totp.now)) login page.find('#error_flash').text.must_equal 'You need to authenticate via an additional factor before continuing' page.current_path.must_equal '/otp-auth' app.rodauth.otp_disable(account_login: 'foo@example.com') # not considered authenticated visit '/' page.find('#error_flash').text.must_equal 'You need to authenticate via an additional factor before continuing' # cannot hijack account by setting up TOTP visit '/otp-setup' page.find('#error_flash').text.must_equal 'You need to authenticate via an additional factor before continuing' end it "should not accept pending sms codes when signing in" do sms_phone = sms_message = nil rodauth do enable :login, :logout, :otp, :sms_codes sms_codes_primary? true sms_send do |phone, msg| sms_phone = phone sms_message = msg end end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/otp-auth' unless rodauth.authenticated? view :content=>"With 2FA" else view :content=>"Without 2FA" end end login visit '/otp-setup' page.title.must_equal 'Setup TOTP Authentication' page.html.must_include ''0123456789' fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' visit '/sms-setup' page.title.must_equal 'Setup SMS Backup Number' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication needs confirmation' sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS confirmation code for www\.example\.com is \d{12}\z/) logout login reset_otp_last_use fill_in 'Authentication Code', :with=>"#{totp.now[0..2]} #{totp.now[3..-1]}" click_button 'Authenticate Using TOTP' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With 2FA' reset_otp_last_use visit '/sms-setup' page.find('#error_flash').text.must_equal 'SMS authentication needs confirmation' page.title.must_equal 'Confirm SMS Backup Number' end it "should automatically clear expired SMS confirm codes" do sms_phone = sms_message = nil rodauth do enable :login, :logout, :sms_codes sms_codes_primary? true two_factor_modifications_require_password? false sms_send do |phone, msg| sms_phone = phone sms_message = msg end end roda do |r| r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/otp-auth' unless rodauth.authenticated? view :content=>"With 2FA" else view :content=>"Without 2FA" end end login visit '/sms-setup' page.title.must_equal 'Setup SMS Backup Number' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication needs confirmation' sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS confirmation code for www\.example\.com is \d{12}\z/) visit '/sms-setup' page.current_path.must_equal '/sms-confirm' page.find('#error_flash').text.must_equal 'SMS authentication needs confirmation' DB[:account_sms_codes].update(:code_issued_at=>Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, seconds: 90000)) visit '/sms-setup' DB[:account_sms_codes].must_be_empty page.current_path.must_equal '/sms-setup' fill_in 'Phone Number', :with=>'(123) 456-7891' click_button 'Setup SMS Backup Number' sms_phone.must_equal '1234567891' sms_message =~ /\ASMS confirmation code for www\.example\.com is (\d{12})\z/ code = $1 code.wont_be_nil page.find('#notice_flash').text.must_equal 'SMS authentication needs confirmation' fill_in 'SMS Code', :with=>code DB[:account_sms_codes].select_map(:num_failures).must_equal [nil] click_button "Confirm SMS Backup Number" DB[:account_sms_codes].select_map(:num_failures).must_equal [0] page.find('#notice_flash').text.must_equal 'SMS authentication has been setup' page.current_path.must_equal '/' end begin require 'webauthn/fake_client' rescue LoadError else [true, false].each do |before| it "should automatically remove recovery codes once last MFA method is removed if auto_add_recovery_codes? is set to true, when recovery_codes is loaded #{before ? 'before' : 'after'}" do sms_message = nil hmac_secret = '123' rodauth do features = [:otp, :sms_codes, :webauthn, :recovery_codes] features.reverse! if before enable :login, :logout, *features hmac_secret do hmac_secret end sms_codes_primary? true sms_send do |phone, msg| sms_message = msg end auto_add_recovery_codes? true auto_remove_recovery_codes? true end first_request = nil roda do |r| first_request ||= r r.rodauth r.redirect '/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/otp-auth' unless rodauth.authenticated? view :content=>"With 2FA" else view :content=>"Without 2FA" end end login origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) DB[:account_recovery_codes].must_be_empty # Doesn't remove recovery codes after OTP disable with OTP & SMS MFA setup visit '/otp-setup' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Authentication Code', :with=>totp.now fill_in 'Password', :with=>'0123456789' click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' visit '/sms-setup' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' sms_code = sms_message[/\d{12}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication has been setup' DB[:account_otp_keys].wont_be_empty DB[:account_sms_codes].wont_be_empty DB[:account_recovery_codes].wont_be_empty visit '/otp-disable' fill_in 'Password', :with=>'0123456789' click_button 'Disable TOTP Authentication' DB[:account_otp_keys].must_be_empty DB[:account_recovery_codes].wont_be_empty # Removes recovery codes with only SMS setup click_link 'Authenticate Using Recovery Code' fill_in 'Recovery Code', :with=>DB[:account_recovery_codes].first[:code] click_button 'Authenticate via Recovery Code' visit '/sms-disable' fill_in 'Password', :with=>'0123456789' click_button 'Disable Backup SMS Authentication' DB[:account_sms_codes].must_be_empty DB[:account_recovery_codes].must_be_empty # Doesn't remove recovery codes after WebAuthn disable with WebAuthn & OTP MFA setup visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json fill_in 'Password', :with=>'0123456789' click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' visit '/otp-setup' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Authentication Code', :with=>totp.now fill_in 'Password', :with=>'0123456789' click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' DB[:account_webauthn_keys].wont_be_empty DB[:account_otp_keys].wont_be_empty DB[:account_recovery_codes].wont_be_empty visit '/webauthn-remove' fill_in 'Password', :with=>'0123456789' choose "webauthn-remove-#{ DB[:account_webauthn_keys].first[:webauthn_id] }" click_button 'Remove WebAuthn Authenticator' DB[:account_webauthn_keys].must_be_empty DB[:account_recovery_codes].wont_be_empty # Removes recovery codes with only OTP setup visit '/otp-disable' fill_in 'Password', :with=>'0123456789' click_button 'Disable TOTP Authentication' DB[:account_otp_keys].must_be_empty DB[:account_recovery_codes].must_be_empty # Doesn't remove recovery codes after SMS disable with SMS & WebAuthn MFA setup visit '/sms-setup' fill_in 'Password', :with=>'0123456789' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' sms_code = sms_message[/\d{12}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication has been setup' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json fill_in 'Password', :with=>'0123456789' click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' DB[:account_sms_codes].wont_be_empty DB[:account_webauthn_keys].wont_be_empty DB[:account_recovery_codes].wont_be_empty visit '/sms-disable' fill_in 'Password', :with=>'0123456789' click_button 'Disable Backup SMS Authentication' DB[:account_sms_codes].must_be_empty DB[:account_recovery_codes].wont_be_empty # Removes recovery codes with only WebAuthn setup visit '/webauthn-remove' fill_in 'Password', :with=>'0123456789' choose "webauthn-remove-#{ DB[:account_webauthn_keys].first[:webauthn_id] }" click_button 'Remove WebAuthn Authenticator' DB[:account_webauthn_keys].must_be_empty DB[:account_recovery_codes].must_be_empty end it "should handle webauthn, otp, sms, and recovery codes in use together, when loading webauthn #{before ? "before" : "after"}" do recovery_codes_primary = sms_codes_primary = false sms_phone = sms_message = nil require_password = false rodauth do features = [:otp, :sms_codes, :recovery_codes, :webauthn] features.reverse! if before enable :login, :logout, *features hmac_secret '123' sms_send do |phone, msg| sms_phone = phone sms_message = msg end two_factor_modifications_require_password?{require_password} sms_codes_primary?{sms_codes_primary} recovery_codes_primary?{recovery_codes_primary} before_sms_setup{remove_instance_variable(:@sms)} end first_request = nil roda do |r| first_request ||= r r.rodauth rodauth.require_login r.on('2') do rodauth.require_authentication rodauth.require_two_factor_setup view :content=>"With Required 2nd Factor: #{rodauth.authenticated_by.last}" end if rodauth.two_factor_authentication_setup? rodauth.require_authentication view :content=>"With 2nd Factor: #{rodauth.authenticated_by.last}" else view :content=>"Without 2nd Factor" end end login page.html.must_include 'Without 2nd Factor' origin = first_request.base_url webauthn_client1 = WebAuthn::FakeClient.new(origin) webauthn_client2 = WebAuthn::FakeClient.new(origin) %w'/multifactor-auth /multifactor-disable'.each do |path| visit path page.find('#error_flash').text.must_equal 'This account has not been setup for multifactor authentication' page.current_path.must_equal '/multifactor-manage' end visit '/2' page.title.must_equal 'Manage Multifactor Authentication' page.html.must_match(/Setup Multifactor Authentication.*Setup WebAuthn Authentication.*Setup TOTP Authentication/m) page.html.wont_include 'Remove Multifactor Authentication' page.html.wont_include 'Setup Backup SMS Authentication' page.html.wont_include 'View Authentication Recovery Codes' sms_codes_primary = true visit page.current_path page.html.must_match(/Setup WebAuthn Authentication.*Setup TOTP Authentication.*Setup Backup SMS Authentication/m) page.html.wont_include 'View Authentication Recovery Codes' sms_codes_primary = false recovery_codes_primary = true visit page.current_path page.html.must_match(/Setup WebAuthn Authentication.*Setup TOTP Authentication.*View Authentication Recovery Codes/m) page.html.wont_include 'Setup Backup SMS Authentication' sms_codes_primary = true visit page.current_path page.html.must_match(/Setup WebAuthn Authentication.*Setup TOTP Authentication.*Setup Backup SMS Authentication.*View Authentication Recovery Codes/m) recovery_codes_primary = sms_codes_primary = false click_link 'Setup WebAuthn Authentication' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client1.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With 2nd Factor: webauthn' visit '/2' page.html.must_include 'With Required 2nd Factor: webauthn' logout login page.title.must_equal 'Authenticate Using WebAuthn' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client1.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.current_path.must_equal '/' page.html.must_include 'With 2nd Factor: webauthn' visit '/multifactor-manage' page.html.must_match(/Setup Multifactor Authentication.*Setup WebAuthn Authentication.*Setup TOTP Authentication.*Setup Backup SMS Authentication.*View Authentication Recovery Codes.*Remove Multifactor Authentication.*Remove WebAuthn Authenticator/m) click_link 'Setup TOTP Authentication' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With 2nd Factor: webauthn' reset_otp_last_use logout login page.title.must_equal 'Authenticate Using Additional Factor' page.html.must_match(/Authenticate Using WebAuthn.*Authenticate Using TOTP/m) page.html.wont_include 'Authenticate Using SMS Code' click_link 'Authenticate Using TOTP' fill_in 'Authentication Code', :with=>totp.now click_button 'Authenticate Using TOTP' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With 2nd Factor: totp' reset_otp_last_use visit '/multifactor-manage' page.html.must_match(/Setup Multifactor Authentication.*Setup WebAuthn Authentication.*Setup Backup SMS Authentication.*View Authentication Recovery Codes.*Remove Multifactor Authentication.*Remove WebAuthn Authenticator.*Disable TOTP Authentication/m) page.html.wont_include 'Setup TOTP Authentication' click_link 'View Authentication Recovery Codes' click_button 'View Authentication Recovery Codes' click_button 'Add Authentication Recovery Codes' page.find('#notice_flash').text.must_equal "Additional authentication recovery codes have been added" page.current_path.must_equal '/recovery-codes' visit '/multifactor-manage' click_link 'Setup WebAuthn Authentication' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client2.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With 2nd Factor: totp' visit '/multifactor-manage' click_link 'Setup Backup SMS Authentication' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication needs confirmation' sms_phone.must_equal '1234567890' sms_message.must_match(/\ASMS confirmation code for www\.example\.com is \d{12}\z/) sms_code = sms_message[/\d{12}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication has been setup' page.html.must_include 'With 2nd Factor: totp' logout login page.html.must_match(/Authenticate Using WebAuthn.*Authenticate Using TOTP.*Authenticate Using SMS Code.*Authenticate Using Recovery Code/m) click_link 'Authenticate Using SMS Code' click_button 'Send SMS Code' sms_code = sms_message[/\d{6}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Authenticate via SMS Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With 2nd Factor: sms_code' visit '/multifactor-manage' page.html.must_match(/Setup Multifactor Authentication.*Setup WebAuthn Authentication.*View Authentication Recovery Codes.*Remove Multifactor Authentication.*Remove WebAuthn Authenticator.*Disable TOTP Authentication.*Disable SMS Authentication/m) page.html.wont_include 'Setup TOTP Authentication' page.html.wont_include 'Setup Backup SMS Authentication' click_link 'View Authentication Recovery Codes' page.title.must_equal 'View Authentication Recovery Codes' click_button 'View Authentication Recovery Codes' recovery_codes = find('#recovery-codes').text.split recovery_codes.length.must_equal 16 recovery_code = recovery_codes.first logout login page.html.must_match(/Authenticate Using WebAuthn.*Authenticate Using TOTP.*Authenticate Using SMS Code.*Authenticate Using Recovery Code/m) click_link 'Authenticate Using Recovery Code' fill_in 'Recovery Code', :with=>recovery_code click_button 'Authenticate via Recovery Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.html.must_include 'With 2nd Factor: recovery_code' require_password = true visit '/multifactor-manage' click_link 'Remove All Multifactor Authentication Methods' page.title.must_equal 'Remove All Multifactor Authentication Methods' click_button 'Remove All Multifactor Authentication Methods' page.find('#error_flash').text.must_equal 'Unable to remove all multifactor authentication methods' page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'Remove All Multifactor Authentication Methods' page.find('#notice_flash').text.must_equal 'All multifactor authentication methods have been disabled' page.html.must_include 'Without 2nd Factor' [:account_webauthn_user_ids, :account_webauthn_keys, :account_otp_keys, :account_recovery_codes, :account_sms_codes].each do |t| DB[t].count.must_equal 0 end end end it "should remove 2FA session when removing all authentication methods" do sms_message = nil rodauth do enable :login, :logout, :otp, :sms_codes, :recovery_codes, :webauthn hmac_secret '123' two_factor_modifications_require_password? false sms_send { |phone, msg| sms_message = msg } recovery_codes_primary? true sms_codes_primary? true end first_request = nil roda do |r| first_request ||= r r.rodauth rodauth.require_authentication view :content=>"2FA authenticated: #{rodauth.two_factor_authenticated?}" end login webauthn_client = WebAuthn::FakeClient.new(first_request.base_url) visit '/otp-setup' secret = page.html.match(/Secret: ([a-z2-7]{#{secret_length}})/)[1] totp = ROTP::TOTP.new(secret) fill_in 'Authentication Code', :with=>totp.now click_button 'Setup TOTP Authentication' page.find('#notice_flash').text.must_equal 'TOTP authentication is now setup' visit '/multifactor-disable' click_button 'Remove All Multifactor Authentication Methods' page.find('#notice_flash').text.must_equal 'All multifactor authentication methods have been disabled' page.html.must_include '2FA authenticated: false' visit '/recovery-codes' click_on 'View Authentication Recovery Codes' click_on 'Add Authentication Recovery Codes' page.find('#notice_flash').text.must_equal 'Additional authentication recovery codes have been added' recovery_code = find('#recovery-codes').text.split.first logout login fill_in 'Recovery Code', :with=>recovery_code click_button 'Authenticate via Recovery Code' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' visit '/multifactor-disable' click_button 'Remove All Multifactor Authentication Methods' page.find('#notice_flash').text.must_equal 'All multifactor authentication methods have been disabled' page.html.must_include '2FA authenticated: false' visit '/sms-setup' fill_in 'Phone Number', :with=>'(123) 456-7890' click_button 'Setup SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication needs confirmation' sms_code = sms_message[/\d{12}\z/] fill_in 'SMS Code', :with=>sms_code click_button 'Confirm SMS Backup Number' page.find('#notice_flash').text.must_equal 'SMS authentication has been setup' visit '/multifactor-disable' click_button 'Remove All Multifactor Authentication Methods' page.find('#notice_flash').text.must_equal 'All multifactor authentication methods have been disabled' page.html.must_include '2FA authenticated: false' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' visit '/multifactor-disable' click_button 'Remove All Multifactor Authentication Methods' page.find('#notice_flash').text.must_equal 'All multifactor authentication methods have been disabled' page.html.must_include '2FA authenticated: false' end end end jeremyevans-rodauth-b53f402/spec/update_password_hash_spec.rb000066400000000000000000000142441515725514200245510ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth update_password feature' do [false, true].each do |ph| it "should support updating passwords for accounts #{'with account_password_hash_column' if ph} if hash cost changes" do cost = if RODAUTH_ALWAYS_ARGON2 if Argon2::VERSION >= '2.1' {t_cost: 1, m_cost: 6, p_cost: 2} else {t_cost: 1, m_cost: 6} end else BCrypt::Engine::MIN_COST end rodauth do enable :login, :logout, :update_password_hash account_password_hash_column :ph if ph password_hash_cost{cost} end roda do |r| r.rodauth next unless rodauth.logged_in? rodauth.account_from_session r.root{rodauth.send(:get_password_hash)} end login content = page.html logout login page.current_path.must_equal '/' content.must_equal page.html if RODAUTH_ALWAYS_ARGON2 cost = if Argon2::VERSION >= '2.1' {t_cost: 1, m_cost: 5, p_cost: 2} else {t_cost: 1, m_cost: 5} end else cost += 1 end logout login new_content = page.html page.current_path.must_equal '/' content.wont_equal new_content logout login page.current_path.must_equal '/' new_content.must_equal page.html end end it "should handle case where the user does not have a password" do rodauth do enable :login, :logout, :update_password_hash, :change_password account_password_hash_column :ph require_password_confirmation? false end roda do |r| r.rodauth r.root{view(:content=>rodauth.logged_in? ? 'Logged In' : 'Not Logged')} end login DB[:accounts].update(:ph=>nil) visit '/change-password' fill_in 'New Password', :with=>'0123456789' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" login page.html.must_include 'Logged In' end end unless ENV['RODAUTH_NO_ARGON2'] == '1' begin require 'argon2' rescue LoadError else describe 'Rodauth update_password feature' do [false, true].each do |ph| it "should support updating passwords for accounts #{'with account_password_hash_column' if ph} if hash algorithm changes from bcrypt to argon2" do rodauth do enable :login, :logout, :update_password_hash, :argon2 account_password_hash_column :ph if ph end roda do |r| r.rodauth next unless rodauth.logged_in? rodauth.account_from_session r.root{rodauth.send(:get_password_hash)} end login content = page.html logout login page.current_path.must_equal '/' content.must_equal page.html end it "should support updating passwords for accounts #{'with account_password_hash_column' if ph} if argon2_secret changes" do secret = old_secret = nil rodauth do enable :login, :logout, :update_password_hash, :argon2 account_password_hash_column :ph if ph argon2_secret{secret} argon2_old_secret{old_secret} end roda do |r| r.rodauth next unless rodauth.logged_in? rodauth.account_from_session r.root{rodauth.send(:get_password_hash)} end login content = page.html secret = '1' logout login page.current_path.must_equal '/' content.wont_equal page.html secret = '2' logout login page.current_path.must_equal '/login' old_secret = '1' login page.current_path.must_equal '/' content.wont_equal page.html secret = nil logout login page.current_path.must_equal '/login' old_secret = '2' logout login page.current_path.must_equal '/' content.wont_equal page.html end end end describe 'Rodauth update_password feature' do around(:all) do |&block| DB.transaction(:rollback=>:always) do hasher = ::Argon2::Password.new({ t_cost: 1, m_cost: 5, p_cost: 2 }) hash = hasher.create('01234567') DB[PASSWORD_HASH_TABLE].insert(:id=>DB[:accounts].insert(:email=>'foo2@example.com', :status_id=>2, :ph=>hash), :password_hash=>hash) super(&block) end end [false, true].each do |ph| it "should support updating passwords for accounts #{'with account_password_hash_column' if ph} if hash cost changes via argon2" do cost = { t_cost: 1, m_cost: 5, p_cost: 2 } rodauth do enable :login, :logout, :update_password_hash, :argon2 account_password_hash_column :ph if ph password_hash_cost{cost} end roda do |r| r.rodauth next unless rodauth.logged_in? rodauth.account_from_session r.root{rodauth.send(:get_password_hash)} end login(:login=>'foo2@example.com', :pass=>'01234567') content = page.html logout login(:login=>'foo2@example.com', :pass=>'01234567') page.current_path.must_equal '/' content.must_equal page.html cost = { t_cost: 2, m_cost: 5, p_cost: 2 } logout login(:login=>'foo2@example.com', :pass=>'01234567') new_content = page.html page.current_path.must_equal '/' content.wont_equal new_content logout login(:login=>'foo2@example.com', :pass=>'01234567') page.current_path.must_equal '/' new_content.must_equal page.html end if Argon2::VERSION >= '2.1' it "should support updating passwords for accounts #{'with account_password_hash_column' if ph} if hash algorithm changes from argon2 to bcrypt" do rodauth do enable :login, :logout, :update_password_hash, :argon2 account_password_hash_column :ph if ph use_argon2? false end roda do |r| r.rodauth next unless rodauth.logged_in? rodauth.account_from_session r.root{rodauth.send(:get_password_hash)} end login(:login=>'foo2@example.com', :pass=>'01234567') content = page.html logout login(:login=>'foo2@example.com', :pass=>'01234567') page.current_path.must_equal '/' content.must_equal page.html end end end end end jeremyevans-rodauth-b53f402/spec/verify_account_grace_period_spec.rb000066400000000000000000000336261515725514200260720ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth verify_account_grace_period feature' do it "should support grace periods when verifying accounts" do rodauth do enable :login, :logout, :change_password, :create_account, :verify_account_grace_period change_password_requires_password? false end roda do |r| r.rodauth r.get('in-period'){rodauth.send(:account_in_unverified_grace_period?).to_s} r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.verified_account?}" : "Not Logged"} end visit '/in-period' page.body.must_equal 'false' visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') page.body.must_include('Logged Infalse') page.current_path.must_equal '/' visit '/in-period' page.body.must_equal 'true' logout login(:login=>'foo@example2.com') page.body.must_include('Logged Infalse') visit '/change-password' fill_in 'New Password', :with=>'012345678' fill_in 'Confirm Password', :with=>'012345678' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" DB[:account_verification_keys].update(:requested_at=>Time.now - 100000) logout login(:login=>'foo@example2.com', :pass=>'012345678') page.find('#error_flash').text.must_equal 'The account you tried to login with is currently awaiting verification' visit '/' page.body.must_include('Not Logged') visit link click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.body.must_include('Logged Intrue') visit '/in-period' page.body.must_equal 'false' end it "should support nil grace period" do period = 86400 rodauth do enable :login, :logout, :change_password, :create_account, :verify_account_grace_period change_password_requires_password? false verify_account_grace_period{period} end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.verified_account?}" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') page.body.must_include('Logged Infalse') page.current_path.must_equal '/' logout login(:login=>'foo@example2.com') page.body.must_include('Logged Infalse') logout period = nil login(:login=>'foo@example2.com') page.find('#error_flash').text.must_equal 'The account you tried to login with is currently awaiting verification' visit '/' page.body.must_include('Not Logged') visit link click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.body.must_include('Logged Intrue') logout login(:login=>'foo@example2.com') page.body.must_include('Logged Intrue') visit '/change-password' fill_in 'New Password', :with=>'012345678' fill_in 'Confirm Password', :with=>'012345678' click_button 'Change Password' page.find('#notice_flash').text.must_equal "Your password has been changed" end it "should resend verify account email if attempting to create new account with same login" do rodauth do enable :login, :logout, :change_password, :create_account, :verify_account_grace_period change_password_requires_password? false verify_account_email_last_sent_column nil end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.verified_account?}" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') page.body.must_include('Logged Infalse') page.current_path.must_equal '/' logout visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' click_button 'Send Verification Email Again' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com').must_equal link visit link click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.body.must_include('Logged Intrue') end [true, false].each do |before| it "should not allow changing logins for unverified accounts, when loading verify_account_grace_period #{before ? "before" : "after"}" do rodauth do features = [:change_login, :verify_account_grace_period] features.reverse! if before enable :login, :logout, *features change_login_requires_password? false end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.verified_account?}" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') visit '/change-login' page.find('#error_flash').text.must_equal "Please verify this account before changing the login" page.current_path.must_equal '/' visit link click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.body.must_include('Logged Intrue') visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' click_button 'Change Login' page.find('#notice_flash').text.must_equal "Your login has been changed" page.current_path.must_equal '/' end end it "should allow verifying accounts while logged in during grace period" do rodauth do enable :login, :verify_account_grace_period already_logged_in{request.redirect '/'} end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.verified_account?}" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') page.body.must_include('Logged Infalse') page.current_path.must_equal '/' visit link click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.body.must_include('Logged Intrue') end it "should ask for account verification on login attempt without password" do rodauth do enable :login, :logout, :verify_account_grace_period verify_account_set_password? true end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.verified_account?}" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') logout visit '/login' fill_in 'Login', :with=>'foo@example2.com' click_button 'Login' page.find('#error_flash').text.must_equal "The account you tried to login with is currently awaiting verification" page.html.must_include "Send Verification Email Again" page.html.wont_include "Login" end it "should allow closing accounts during grace period without password" do rodauth do enable :login, :close_account, :verify_account_grace_period, :password_grace_period verify_account_set_password? true end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.verified_account?}" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') visit '/close-account' click_button 'Close Account' page.current_path.must_equal '/' DB[:accounts].where(:email=>'foo@example2.com').get(:status_id).must_equal 3 end [true, false].each do |before| it "should remove verify keys if closing unverified accounts, when loading verify_account_grace_period #{before ? "before" : "after"}" do rodauth do features = [:close_account, :verify_account_grace_period] features.reverse! if before enable :login, *features already_logged_in{request.redirect '/'} close_account_requires_password? false end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.verified_account?}" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') visit '/close-account' click_button 'Close Account' page.find('#notice_flash').text.must_equal "Your account has been closed" DB[:account_verification_keys].must_be :empty? end it "should not support email authentication for unverified accounts in grace period, when loading verify_account_grace_period #{before ? "before" : "after"}" do rodauth do features = [:email_auth, :verify_account_grace_period] features.reverse! if before enable :login, *features enable :login, :logout, :change_password, :create_account, *features end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In#{rodauth.verified_account?}" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') page.body.must_include('Logged Infalse') page.current_path.must_equal '/' logout visit '/login' fill_in 'Login', :with=>'foo@example2.com' click_button 'Login' page.body.wont_include('Send Login Link Via Email') end end it "should work with email_auth and two factor authentication when requesting password during verification" do rodauth do enable :email_auth, :verify_account_grace_period, :otp verify_account_set_password? true end roda do |r| r.rodauth r.root{view :content=>"Authenticated? #{rodauth.authenticated?}"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') page.body.must_include('Authenticated? true') end it "should consider session not logged in if unverified grace period expired" do rodauth do enable :verify_account_grace_period verify_account_set_password? true end roda do |r| r.rodauth r.root{view :content=>"Authenticated? #{!!rodauth.logged_in?} #{rodauth.session_value.nil?}"} r.get('require-login'){rodauth.require_login} r.get('expire') do session[rodauth.unverified_account_session_key] -= 100000 r.redirect '/' end end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') page.body.must_include('Authenticated? true false') visit '/expire' page.body.must_include "Authenticated? false false" visit '/require-login' visit '/' page.body.must_include "Authenticated? false true" end it "should allow already established sessions without grace period expiration timestamps" do rodauth do enable :verify_account_grace_period verify_account_set_password? true end roda do |r| r.rodauth rodauth.require_login if rodauth.logged_in? r.root{view :content=>"Authenticated? #{!!rodauth.logged_in?}"} r.get('migrate') do session[rodauth.unverified_account_session_key] = true r.redirect '/' end end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') page.body.must_include('Authenticated? true') visit '/migrate' page.body.must_include('Authenticated? true') end end jeremyevans-rodauth-b53f402/spec/verify_account_spec.rb000066400000000000000000000511551515725514200233640ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth verify_account feature' do it "should support verifying accounts" do last_sent_column = nil secret = old_secret = nil allow_raw_token = false rodauth do enable :login, :create_account, :verify_account verify_account_autologin? false verify_account_email_last_sent_column{last_sent_column} hmac_secret{secret} hmac_old_secret{old_secret} allow_raw_email_token?{allow_raw_token} verify_account_set_password? false require_login_confirmation? true end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Confirm Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') login(:login=>'foo@example2.com') page.find('#error_flash').text.must_equal 'The account you tried to login with is currently awaiting verification' page.html.must_include("If you no longer have the email to verify the account, you can request that it be resent to you") page.status_code.must_equal 403 click_button 'Send Verification Email Again' page.current_path.must_equal '/' email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com').must_equal link visit '/login' click_link 'Resend Verify Account Information' fill_in 'Login', :with=>'foo@example2.com' click_button 'Send Verification Email Again' page.current_path.must_equal '/' email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com').must_equal link visit '/login' last_sent_column = :email_last_sent click_link 'Resend Verify Account Information' fill_in 'Login', :with=>'foo@example2.com' click_button 'Send Verification Email Again' page.current_path.must_equal '/' page.find('#error_flash').text.must_equal "An email has recently been sent to you with a link to verify your account" Mail::TestMailer.deliveries.must_equal [] visit '/login' DB[:account_verification_keys].update(:email_last_sent => Time.now - 250).must_equal 1 click_link 'Resend Verify Account Information' fill_in 'Login', :with=>'foo@example2.com' click_button 'Send Verification Email Again' page.current_path.must_equal '/' page.find('#error_flash').text.must_equal "An email has recently been sent to you with a link to verify your account" Mail::TestMailer.deliveries.must_equal [] visit '/login' DB[:account_verification_keys].update(:email_last_sent => Time.now - 350).must_equal 1 click_link 'Resend Verify Account Information' fill_in 'Login', :with=>'foo@example2.com' click_button 'Send Verification Email Again' page.current_path.must_equal '/' email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com').must_equal link DB[:account_verification_keys].update(:email_last_sent => Time.now - 350).must_equal 1 visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#error_flash').text.must_equal "The account you tried to create is currently awaiting verification" page.html.must_include("If you no longer have the email to verify the account, you can request that it be resent to you") page.status_code.must_equal 403 click_button 'Send Verification Email Again' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') visit link[0...-1] page.find('#error_flash').text.must_equal "There was an error verifying your account: invalid verify account key" secret = SecureRandom.random_bytes(32) visit link page.find('#error_flash').text.must_equal "There was an error verifying your account: invalid verify account key" secret = SecureRandom.random_bytes(32) old_secret = SecureRandom.random_bytes(32) visit link page.find('#error_flash').text.must_equal "There was an error verifying your account: invalid verify account key" allow_raw_token = true visit link click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.current_path.must_equal '/' login(:login=>'foo@example2.com') page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' end [false, true].each do |ph| it "should support setting passwords when verifying accounts #{'with account_password_hash_column' if ph}" do initial_secret = secret = SecureRandom.random_bytes(32) old_secret = nil rodauth do enable :login, :create_account, :verify_account account_password_hash_column :ph if ph verify_account_autologin? false hmac_secret{secret} hmac_old_secret{old_secret} end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') secret = SecureRandom.random_bytes(32) visit link page.find('#error_flash').text.must_equal "There was an error verifying your account: invalid verify account key" secret = SecureRandom.random_bytes(32) old_secret = SecureRandom.random_bytes(32) visit link page.find('#error_flash').text.must_equal "There was an error verifying your account: invalid verify account key" old_secret = initial_secret visit link page.find_by_id('password')[:autocomplete].must_equal 'new-password' secret = initial_secret visit link page.find_by_id('password')[:autocomplete].must_equal 'new-password' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'012345678' click_button 'Verify Account' page.html.must_include("passwords do not match") page.find('#error_flash').text.must_equal "Unable to verify account" fill_in 'Password', :with=>'0123' fill_in 'Confirm Password', :with=>'0123' click_button 'Verify Account' page.html.must_include("invalid password, does not meet requirements (minimum 6 characters)") page.find('#error_flash').text.must_equal "Unable to verify account" fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.current_path.must_equal '/' login(:login=>'foo@example2.com', :password=>'0123456789') page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' end end it "should indicate when resending verification email does not occur due to missing key" do rodauth do enable :login, :verify_account end roda do |r| r.rodauth r.root{view :content=>""} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') login(:login=>'foo@example2.com') page.find('#error_flash').text.must_equal 'The account you tried to login with is currently awaiting verification' DB[:account_verification_keys].delete click_button 'Send Verification Email Again' page.find('#error_flash').text.must_equal 'Unable to resend verify account email' visit '/verify-account' page.find('#error_flash').text.must_equal 'There was an error verifying your account: invalid verify account key' end it "should support autologin when verifying accounts" do rodauth do enable :login, :create_account, :verify_account end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') visit link fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.body.must_include 'Logged In' end it "should handle uniqueness errors raised when inserting verify account token" do rodauth do enable :login, :verify_account end roda do |r| def rodauth.raised_uniqueness_violation(*) super; true; end r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') visit link fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.body.must_include 'Logged In' end it "should handle uniqueness errors raised when inserting verify account token, if there isn't a matching key, by reraising" do rodauth do enable :login, :verify_account end roda do |r| def rodauth.raised_uniqueness_violation(*, &_) StandardError.new; end r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' proc{click_button 'Create Account'}.must_raise StandardError end it "should not attempt to insert a verify account key if one already exists" do rodauth do enable :login, :verify_account create_verify_account_key do super() def self.raised_uniqueness_violation(*) raise ArgumentError; end super() end end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') visit link fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.body.must_include 'Logged In' end it "should hash the password only once when using password hash column" do rodauth do enable :login, :create_account, :verify_account account_password_hash_column :ph password_hash do |password| bcrypt_password = super(password) def bcrypt_password.==(other) raise "should not have been called" end bcrypt_password end end roda do |r| r.rodauth r.root{view :content=>""} end visit "/create-account" fill_in 'Login', :with=>'foo2@example.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" link = email_link(/(\/verify-account\?key=.+)$/, 'foo2@example.com') visit link fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_on 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" end it "should support accessing verify-account-resend route when not logged in and verify_account_resend_explanatory_text calls verify_account_email_recently_sent?" do rodauth do enable :login, :verify_account verify_account_skip_resend_email_within(-1) verify_account_resend_explanatory_text{super() if verify_account_email_recently_sent?} end roda do |r| r.rodauth r.root{view :content=>"Home"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' Mail::TestMailer.deliveries.size.must_equal 1 visit '/verify-account-resend' page.title.must_equal 'Resend Verification Email' fill_in 'Login', :with=>'foo@example2.com' click_button 'Send Verification Email Again' Mail::TestMailer.deliveries.size.must_equal 2 visit '/verify-account-resend?login=foo@example2.com' page.html.wont_include "Login" page.title.must_equal 'Resend Verification Email' click_button 'Send Verification Email Again' Mail::TestMailer.deliveries.size.must_equal 3 Mail::TestMailer.deliveries.clear end it "should not display verify account resend link on login page when route is disabled" do route = "verify-account-resend" rodauth do enable :login, :create_account, :verify_account verify_account_resend_route { route } end roda do |r| r.rodauth r.root{view :content=>"Home"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' Mail::TestMailer.deliveries.clear visit '/login' page.html.must_include "Resend Verify Account Information" route = nil visit '/login' page.html.wont_include "Resend Verify Account Information" end [:jwt, :json].each do |json| it "should support verifying accounts via #{json}" do rodauth do enable :login, :create_account, :verify_account verify_account_autologin? false verify_account_email_body{verify_account_email_link} verify_account_set_password? false verify_account_email_last_sent_column nil end roda(json) do |r| r.rodauth r.root{view :content=>""} end res = json_request('/create-account', :login=>'foo@example2.com', :password=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [200, {'success'=>"An email has been sent to you with a link to verify your account"}] link = email_link(/key=.+$/, 'foo@example2.com') res = json_request('/create-account', :login=>'foo@example2.com', :password=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [403, {"reason"=>"already_an_unverified_account_with_this_login", "error"=>"The account you tried to create is currently awaiting verification"}] res = json_request('/verify-account-resend', :login=>'foo@example.com') res.must_equal [401, {'reason'=> "no_matching_login", 'error'=>"Unable to resend verify account email"}] res = json_request('/verify-account-resend', :login=>'foo@example3.com') res.must_equal [401, {'reason'=> "no_matching_login", 'error'=>"Unable to resend verify account email"}] res = json_request('/login', :login=>'foo@example2.com',:password=>'0123456789') res.must_equal [403, {'reason'=> "unverified_account", 'error'=>"The account you tried to login with is currently awaiting verification"}] res = json_request('/verify-account-resend', :login=>'foo@example2.com') res.must_equal [200, {'success'=>"An email has been sent to you with a link to verify your account"}] email_link(/key=.+$/, 'foo@example2.com').must_equal link res = json_request('/verify-account') res.must_equal [401, {'reason'=> "invalid_verify_account_key", 'error'=>"Unable to verify account"}] res = json_request('/verify-account', :key=>link[4...-1]) res.must_equal [401, {'reason'=> "invalid_verify_account_key", "error"=>"Unable to verify account"}] res = json_request('/verify-account', :key=>link[4..-1]) res.must_equal [200, {"success"=>"Your account has been verified"}] json_login(:login=>'foo@example2.com') end end it "should allow verifying accounts using internal requests" do rodauth do enable :login, :logout, :verify_account, :internal_request, :change_password verify_account_email_last_sent_column nil domain 'example.com' internal_request_configuration do csrf_tag { |*| fail "must not rely on Roda session" } end end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end proc do app.rodauth.verify_account_resend(:login=>'foo3@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.verify_account(:account_login=>'foo3@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.verify_account_resend(:account_login=>'foo@example.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.verify_account(:account_login=>'foo@example.com') end.must_raise Rodauth::InternalRequestError visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') app.rodauth.verify_account_resend(:account_login=>'foo@example2.com').must_be_nil link2 = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') link2.must_equal link visit link fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Verify Account' page.find('#notice_flash').text.must_equal "Your account has been verified" page.body.must_include 'Logged In' logout login(:login=>'foo@example2.com') page.body.must_include 'Logged In' logout visit '/create-account' fill_in 'Login', :with=>'foo@example3.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.current_path.must_equal '/' link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example3.com') app.rodauth.verify_account_resend(:login=>'foo@example3.com').must_be_nil link2 = email_link(/(\/verify-account\?key=.+)$/, 'foo@example3.com') link2.must_equal link app.rodauth.verify_account(:account_login=>'foo@example3.com', :password=>'0123456789').must_be_nil login(:login=>'foo@example3.com') page.body.must_include 'Logged In' logout app.rodauth.create_account(:login=>'foo@example4.com').must_be_nil link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example4.com') app.rodauth.verify_account_resend(:login=>'foo@example4.com').must_be_nil link2 = email_link(/(\/verify-account\?key=.+)$/, 'foo@example4.com') link2.must_equal link app.rodauth.verify_account(:account_login=>'foo@example4.com', :password=>'0123456789').must_be_nil login(:login=>'foo@example4.com') page.body.must_include 'Logged In' app.rodauth.create_account(:login=>'foo@example5.com').must_be_nil link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example5.com') key = link.split('=').last proc do app.rodauth.create_account(:login=>'foo@example5.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.login(:login=>'foo@example5.com') end.must_raise Rodauth::InternalRequestError proc do app.rodauth.verify_account(:verify_account_key=>key[0...-1], :password=>'0123456789') end.must_raise Rodauth::InternalRequestError app.rodauth.verify_account(:verify_account_key=>key, :password=>'0123456789').must_be_nil login(:login=>'foo@example5.com') page.body.must_include 'Logged In' end end jeremyevans-rodauth-b53f402/spec/verify_login_change_spec.rb000066400000000000000000000272471515725514200243520ustar00rootroot00000000000000require_relative 'spec_helper' describe 'Rodauth verify_login_change feature' do it "should support verifying login changes" do rodauth do enable :login, :logout, :verify_login_change change_login_requires_password? false end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login visit '/change-login' fill_in 'Login', :with=>'foo@example2.com' click_button 'Change Login' link = email_link(/(\/verify-login-change\?key=.+)$/, 'foo@example2.com') page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your login change" visit '/change-login' fill_in 'Login', :with=>'foo@example2.com' click_button 'Change Login' email_link(/(\/verify-login-change\?key=.+)$/, 'foo@example2.com').must_equal link visit '/change-login' fill_in 'Login', :with=>'foo@example3.com' click_button 'Change Login' new_link = email_link(/(\/verify-login-change\?key=.+)$/, 'foo@example3.com') new_link.wont_equal link logout visit '/verify-login-change' page.find('#error_flash').text.must_equal "There was an error verifying your login change: invalid verify login change key" visit link page.find('#error_flash').text.must_equal "There was an error verifying your login change: invalid verify login change key" visit new_link page.title.must_equal 'Verify Login Change' click_button 'Verify Login Change' page.find('#notice_flash').text.must_equal "Your login change has been verified" page.body.must_include('Not Logged') login page.find('#error_flash').text.must_equal "There was an error logging in" login(:login=>'foo@example3.com') page.body.must_include('Logged In') end it "should support verifying login changes with autologin" do rodauth do enable :login, :logout, :verify_login_change verify_login_change_autologin? true change_login_requires_password? false require_login_confirmation? true end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login visit '/change-login' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Confirm Login', :with=>'foo@example2.com' click_button 'Change Login' link = email_link(/(\/verify-login-change\?key=.+)$/, 'foo@example2.com') visit link click_button 'Verify Login Change' page.find('#notice_flash').text.must_equal "Your login change has been verified" page.body.must_include('Logged In') end it "invalides reset password links after login change verified" do rodauth do enable :login, :verify_login_change, :reset_password change_login_requires_password? false require_login_confirmation? false login_meets_requirements?{|login| login.length > 4} end roda do |r| r.rodauth r.root{view :content=>""} end login visit '/login' login(:pass=>'01234567', :visit=>false) click_button 'Request Password Reset' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to reset the password for your account" reset_password_link = email_link(/(\/reset-password\?key=.+)$/) visit '/change-login' fill_in 'Login', :with=>'foo3@example.com' click_button 'Change Login' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your login change" verify_login_change_link = email_link(/(\/verify-login-change\?key=.+)$/, 'foo3@example.com') visit reset_password_link page.title.must_equal "Reset Password" visit verify_login_change_link click_button 'Verify Login Change' page.find('#notice_flash').text.must_equal "Your login change has been verified" visit reset_password_link page.title.must_equal "Login" page.find('#error_flash').text.must_equal "There was an error resetting your password: invalid or expired password reset key" end it "should check for duplicate accounts before sending verify email and before updating login" do rodauth do enable :login, :logout, :verify_login_change, :create_account change_login_requires_password? false create_account_autologin? false require_login_confirmation? true end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end visit '/create-account' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Confirm Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' fill_in 'Confirm Password', :with=>'0123456789' click_button 'Create Account' login visit '/change-login' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Confirm Login', :with=>'foo@example3.com' click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.body.must_include "logins do not match" visit '/change-login' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Confirm Login', :with=>'foo@example2.com' click_button 'Change Login' page.find('#error_flash').text.must_equal "There was an error changing your login" page.body.must_include "invalid login, already an account with this login" visit '/change-login' fill_in 'Login', :with=>'foo@example3.com' fill_in 'Confirm Login', :with=>'foo@example3.com' click_button 'Change Login' link = email_link(/(\/verify-login-change\?key=.+)$/, 'foo@example3.com') logout DB[:accounts].where(:email=>'foo@example2.com').update(:email=>'foo@example3.com') visit link click_button 'Verify Login Change' page.find('#error_flash').text.must_equal "Unable to change login as there is already an account with the new login" page.current_path.must_equal '/login' visit link page.find('#error_flash').text.must_equal "There was an error verifying your login change: invalid verify login change key" end it "should handle uniqueness errors raised when inserting verify login change entry" do unique = false rodauth do enable :login, :logout, :verify_login_change change_login_requires_password? false auth_class_eval do define_method(:raised_uniqueness_violation) do |*a, &block| unique.call if unique super(*a, &block) end private :raised_uniqueness_violation end end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login visit '/change-login' fill_in 'Login', :with=>'foo@example2.com' click_button 'Change Login' email_link(/(\/verify-login-change\?key=.+)$/, 'foo@example2.com') page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your login change" unique = lambda{DB[:account_login_change_keys].update(:login=>'foo@example3.com'); true} visit '/change-login' fill_in 'Login', :with=>'foo@example2.com' proc{click_button 'Change Login'}.must_raise Sequel::ConstraintViolation end [true, false].each do |before| it "should clear verify login change token when closing account, when loading verify_login_change #{before ? "before" : "after"}" do rodauth do features = [:close_account, :verify_login_change] features.reverse! if before enable :login, *features change_login_requires_password? false end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end login visit '/change-login' fill_in 'Login', :with=>'foo@example2.com' click_button 'Change Login' email_link(/key=.+$/, 'foo@example2.com').wont_be_nil DB[:account_login_change_keys].count.must_equal 1 visit '/close-account' fill_in 'Password', :with=>'0123456789' click_button 'Close Account' DB[:account_login_change_keys].count.must_equal 0 end end [:jwt, :json].each do |json| it "should support verifying login changes for accounts via #{json}" do rodauth do enable :login, :verify_login_change change_login_requires_password? false verify_login_change_email_body{verify_login_change_email_link} end roda(json) do |r| r.rodauth end json_login res = json_request('/change-login', :login=>'foo2@example.com') res.must_equal [200, {'success'=>"An email has been sent to you with a link to verify your login change"}] link = email_link(/key=.+$/, 'foo2@example.com') res = json_request('/change-login', :login=>'foo2@example.com') res.must_equal [200, {'success'=>"An email has been sent to you with a link to verify your login change"}] email_link(/key=.+$/, 'foo2@example.com').must_equal link res = json_request('/change-login', :login=>'foo3@example.com') res.must_equal [200, {'success'=>"An email has been sent to you with a link to verify your login change"}] new_link = email_link(/key=.+$/, 'foo3@example.com') new_link.wont_equal link res = json_request('/verify-login-change') res.must_equal [401, {"reason"=>"invalid_verify_login_change_key", "error"=>"Unable to verify login change"}] res = json_request('/verify-login-change', :key=>link[4..-1]) res.must_equal [401, {"reason"=>"invalid_verify_login_change_key", "error"=>"Unable to verify login change"}] res = json_request('/verify-login-change', :key=>new_link[4..-1]) res.must_equal [200, {"success"=>"Your login change has been verified"}] res = json_request("/login", :login=>'foo@example.com', :password=>'0123456789') res.must_equal [401, {'reason'=>"no_matching_login",'error'=>"There was an error logging in", "field-error"=>["login", "no matching login"]}] json_login(:login=>'foo3@example.com') end end it "should support verifying login changes using internal requests" do rodauth do enable :login, :logout, :verify_login_change, :internal_request domain 'example.com' end roda do |r| r.rodauth r.root{view :content=>rodauth.logged_in? ? "Logged In" : "Not Logged"} end proc do app.rodauth.verify_login_change(:account_login=>'foo@example.com') end.must_raise Rodauth::InternalRequestError login visit '/change-login' fill_in 'Login', :with=>'foo@example2.com' fill_in 'Password', :with=>'0123456789' click_button 'Change Login' link = email_link(/(\/verify-login-change\?key=.+)$/, 'foo@example2.com') app.rodauth.verify_login_change(:account_login=>'foo@example.com').must_be_nil visit link page.find('#error_flash').text.must_equal "There was an error verifying your login change: invalid verify login change key" login(:login=>'foo@example2.com') page.body.must_include('Logged In') logout app.rodauth.change_login(:account_login=>'foo@example2.com', :login=>'foo@example3.com').must_be_nil link = email_link(/(\/verify-login-change\?key=.+)$/, 'foo@example3.com') app.rodauth.verify_login_change(:account_login=>'foo@example2.com').must_be_nil visit link page.find('#error_flash').text.must_equal "There was an error verifying your login change: invalid verify login change key" login(:login=>'foo@example3.com') page.body.must_include('Logged In') logout app.rodauth.change_login(:account_login=>'foo@example3.com', :login=>'foo@example4.com').must_be_nil key = email_link(/(\/verify-login-change\?key=.+)$/, 'foo@example4.com').split('=').last app.rodauth.verify_login_change(:verify_login_change_key=>key).must_be_nil login(:login=>'foo@example4.com') page.body.must_include('Logged In') end end jeremyevans-rodauth-b53f402/spec/views/000077500000000000000000000000001515725514200201335ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/spec/views/layout-auth-check.str000066400000000000000000000010361515725514200242140ustar00rootroot00000000000000 #{@title} #{"
#{opts[:sessions_convert_symbols] ? flash['error'] : flash[:error]}
" if opts[:sessions_convert_symbols] ? flash['error'] : flash[:error]} #{"
#{opts[:sessions_convert_symbols] ? flash['notice'] : flash[:notice]}
" if opts[:sessions_convert_symbols] ? flash['notice'] : flash[:notice]} Is #{'Not ' unless rodauth.logged_in?}Logged In Is #{'Not ' unless rodauth.authenticated?}Authenticated #{yield} jeremyevans-rodauth-b53f402/spec/views/layout-other.str000066400000000000000000000003641515725514200233240ustar00rootroot00000000000000 Foo #{@title} #{"
#{flash['error2']}
" if flash['error2']} #{"
#{flash['notice2']}
" if flash['notice2']} #{yield} jeremyevans-rodauth-b53f402/spec/views/layout.str000066400000000000000000000006661515725514200222120ustar00rootroot00000000000000 #{@title} #{"
#{opts[:sessions_convert_symbols] ? flash['error'] : flash[:error]}
" if opts[:sessions_convert_symbols] ? flash['error'] : flash[:error]} #{"
#{opts[:sessions_convert_symbols] ? flash['notice'] : flash[:notice]}
" if opts[:sessions_convert_symbols] ? flash['notice'] : flash[:notice]} #{yield} jeremyevans-rodauth-b53f402/spec/views/login.str000066400000000000000000000017301515725514200217760ustar00rootroot00000000000000
#{csrf_tag if respond_to?(:csrf_tag)}
#{rodauth.field_error(rodauth.login_param)}
#{rodauth.field_error(rodauth.password_param)}
jeremyevans-rodauth-b53f402/spec/webauthn_autofill_spec.rb000066400000000000000000000155531515725514200240620ustar00rootroot00000000000000require_relative 'spec_helper' begin require 'webauthn/fake_client' rescue LoadError else describe 'Rodauth webauthn_autofill feature' do it "should handle autofill on login via WebAuthn" do rodauth do enable :logout, :webauthn_autofill, :create_account hmac_secret '123' end first_request = nil roda do |r| first_request ||= r r.rodauth if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/' page.html.must_include 'Not Logged In' origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.html.must_include 'Logged In via password' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'Logged In via password and webauthn' logout visit '/login' page.find("#login")[:autocomplete].must_equal "email webauthn" challenge = JSON.parse(page.find('#webauthn-login-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal "You have been logged in" page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' logout visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' logout DB[:account_webauthn_keys].delete visit '/login' challenge = JSON.parse(page.find('#webauthn-login-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal "There was an error authenticating via WebAuthn" page.current_path.must_equal '/login' visit '/webauthn-autofill-js' page.body.must_include File.binread("javascript/webauthn_autofill.js") visit '/create-account' page.find("#login")[:autocomplete].must_equal "email" end it "should allow disabling autofill" do rodauth do enable :webauthn_autofill hmac_secret '123' webauthn_autofill? false end roda do |r| r.rodauth view :content=>"" end visit "/login" page.has_css?("#webauthn-auth-form").must_equal false end it "should allow webauthn autofill via json" do rodauth do enable :webauthn_autofill, :logout hmac_secret '123' end first_request = nil roda(:json) do |r| first_request ||= r r.rodauth rodauth.authenticated_by || [''] end json_request.must_equal [200, ['']] json_login json_request.must_equal [200, ['password']] origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) res = json_request('/webauthn-setup', :password=>'0123456789') setup_json = res[1].delete("webauthn_setup") challenge = res[1].delete("webauthn_setup_challenge") challenge_hmac = res[1].delete("webauthn_setup_challenge_hmac") webauthn_hash = webauthn_client.create(challenge: setup_json['challenge']) res = json_request('/webauthn-setup', :password=>'0123456789', :webauthn_setup=>webauthn_hash, :webauthn_setup_challenge=>challenge, :webauthn_setup_challenge_hmac=>challenge_hmac) res.must_equal [200, {'success'=>'WebAuthn authentication is now setup'}] json_logout json_request.must_equal [200, ['']] res = json_request('/webauthn-login', :login=>'foo@example.com') res[1]["webauthn_auth"]["allowCredentials"].wont_equal [] res = json_request('/webauthn-login') auth_json = res[1].delete("webauthn_auth") challenge = res[1].delete("webauthn_auth_challenge") challenge_hmac = res[1].delete("webauthn_auth_challenge_hmac") res.must_equal [422, {"field-error"=>["webauthn_auth", "invalid webauthn authentication param"], "error"=>"There was an error authenticating via WebAuthn", "reason"=>"invalid_webauthn_auth_param"}] res = json_request('/webauthn-login', :webauthn_auth=>webauthn_client.get(challenge: auth_json['challenge']), :webauthn_auth_challenge=>challenge, :webauthn_auth_challenge_hmac=>challenge_hmac) res.must_equal [200, {'success'=>'You have been logged in'}] json_request.must_equal [200, ['webauthn']] json_logout DB[:account_webauthn_keys].delete res = json_request('/webauthn-login', :webauthn_auth=>webauthn_client.get(challenge: auth_json['challenge']), :webauthn_auth_challenge=>challenge, :webauthn_auth_challenge_hmac=>challenge_hmac) res.must_equal [422, {"field-error"=>["webauthn_auth", "no webauthn key with given id found"],"error"=>"There was an error authenticating via WebAuthn", "reason"=>"invalid_webauthn_id"}] end it "should support webauthn autofill using internal requests" do rodauth do enable :webauthn_autofill, :internal_request hmac_secret '123' domain "example.com" end roda do |r| end webauthn_client = WebAuthn::FakeClient.new("https://example.com") setup_params = app.rodauth.webauthn_setup_params(account_login: 'foo@example.com') app.rodauth.webauthn_setup( account_login: 'foo@example.com', webauthn_setup: webauthn_client.create(challenge: setup_params[:webauthn_setup][:challenge]), webauthn_setup_challenge: setup_params[:webauthn_setup_challenge], webauthn_setup_challenge_hmac: setup_params[:webauthn_setup_challenge_hmac] ).must_be_nil auth_params = app.rodauth.webauthn_login_params app.rodauth.webauthn_login( webauthn_auth: webauthn_client.get(challenge: auth_params[:webauthn_auth][:challenge]), webauthn_auth_challenge: auth_params[:webauthn_auth_challenge], webauthn_auth_challenge_hmac: auth_params[:webauthn_auth_challenge_hmac] ).must_equal DB[:accounts].get(:id) proc do app.rodauth.webauthn_login_params(login: 'bar@example.com') end.must_raise Rodauth::InternalRequestError end end end jeremyevans-rodauth-b53f402/spec/webauthn_login_spec.rb000066400000000000000000000551401515725514200233470ustar00rootroot00000000000000require_relative 'spec_helper' begin require 'webauthn/fake_client' rescue LoadError else describe 'Rodauth webauthn_login feature' do it "should handle logging in via webauthn authentication without password" do rodauth do enable :logout, :webauthn_login hmac_secret '123' end first_request = nil roda do |r| first_request ||= r r.rodauth if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/' page.html.must_include 'Not Logged In' origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.html.must_include 'Logged In via password' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'Logged In via password and webauthn' logout visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.html.must_include 'Logged In via password' logout visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge+'1').to_json click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal "There was an error authenticating via WebAuthn" page.current_path.must_equal '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' end [true, false].each do |before| it "should handle confirming password as second factor authentication after logging in via webauthn, when loading webauth_login #{before ? "before" : "after"}" do rodauth do features = [:confirm_password, :webauthn_login] features.reverse! if before enable :logout, :jwt, *features hmac_secret '123' end first_request = nil roda do |r| first_request ||= r r.rodauth if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/' page.html.must_include 'Not Logged In' origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.html.must_include 'Logged In via password' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'Logged In via password and webauthn' logout fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' visit '/multifactor-auth' fill_in 'Password', :with=>'0123456789' click_button 'Confirm Password' page.html.must_include 'Logged In via password and webauthn' visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' visit '/multifactor-auth' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.current_path.must_equal '/' page.html.must_include 'Logged In via password and webauthn' end end it "should handle regular two factor webauthn authentication after password authentication" do rodauth do enable :logout, :webauthn_login, :confirm_password hmac_secret '123' end first_request = nil roda do |r| first_request ||= r r.rodauth if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/' page.html.must_include 'Not Logged In' origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.html.must_include 'Logged In via password' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'Logged In via password and webauthn' logout fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.html.must_include 'Logged In via password' visit '/webauthn-auth' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.current_path.must_equal '/' page.html.must_include 'Logged In via password and webauthn' end it "should allow treating user verification as 2nd factor" do rodauth do enable :webauthn_login, :logout hmac_secret '123' webauthn_login_user_verification_additional_factor? true account_password_hash_column :ph end first_request = nil roda do |r| first_request ||= r r.rodauth rodauth.require_authentication r.root{view :content=>"Authenticated by: #{rodauth.authenticated_by}"} end visit '/' origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' logout visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' find("#error_flash").text.must_equal "You need to authenticate via an additional factor before continuing" logout visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge, user_verified: true).to_json click_button 'Authenticate Using WebAuthn' find("#notice_flash").text.must_equal "You have been logged in" page.text.must_include 'Authenticated by: ["webauthn", "webauthn-verification"]' logout DB[:accounts].update(:ph=>nil) visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' find("#notice_flash").text.must_equal "You have been logged in" page.text.must_include 'Authenticated by: ["webauthn"]' end it "should allow returning to requested location when login is required" do rodauth do enable :logout, :webauthn_login hmac_secret '123' login_return_to_requested_location? true end first_request = nil roda do |r| first_request ||= r r.rodauth r.root{view :content=>""} r.get('page') do rodauth.require_login view :content=>"" end end visit '/' origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' logout visit '/page' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.current_path.must_equal '/page' end it "should allow adding and removing WebAuthn authenticators after logging in" do rodauth do enable :logout, :webauthn_login hmac_secret '123' end first_request = nil roda do |r| first_request ||= r r.rodauth if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/' page.html.must_include 'Not Logged In' origin = first_request.base_url webauthn_client1 = WebAuthn::FakeClient.new(origin) webauthn_client2 = WebAuthn::FakeClient.new(origin) visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.html.must_include 'Logged In via password' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client1.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'Logged In via password and webauthn' logout fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] webauthn_hash1 = webauthn_client1.get(challenge: challenge) fill_in 'webauthn_auth', :with=>webauthn_hash1.to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client2.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' logout fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] webauthn_hash2 = webauthn_client2.get(challenge: challenge) fill_in 'webauthn_auth', :with=>webauthn_hash2.to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been logged in' page.html.must_include 'Logged In via webauthn' visit '/webauthn-remove' fill_in 'Password', :with=>'0123456789' choose "webauthn-remove-#{webauthn_hash1["rawId"]}" click_button 'Remove WebAuthn Authenticator' page.find('#notice_flash').text.must_equal "WebAuthn authenticator has been removed" page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' visit '/webauthn-remove' fill_in 'Password', :with=>'0123456789' choose "webauthn-remove-#{webauthn_hash2["rawId"]}" click_button 'Remove WebAuthn Authenticator' page.find('#notice_flash').text.must_equal "WebAuthn authenticator has been removed" page.current_path.must_equal '/' page.html.must_include 'Not Logged In' visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.html.must_include 'Logged In via password' end it "should allow adding and removing WebAuthn authenticators after logging in if there is no password for account" do rodauth do enable :logout, :webauthn_login hmac_secret '123' end first_request = nil roda do |r| first_request ||= r r.rodauth if rodauth.logged_in? view :content=>"Logged In via #{rodauth.authenticated_by.join(' and ')}" else view :content=>"Not Logged In" end end visit '/' page.html.must_include 'Not Logged In' origin = first_request.base_url webauthn_client1 = WebAuthn::FakeClient.new(origin) webauthn_client2 = WebAuthn::FakeClient.new(origin) visit '/login' fill_in 'Login', :with=>'foo@example.com' click_button 'Login' fill_in 'Password', :with=>'0123456789' click_button 'Login' page.html.must_include 'Logged In via password' DB[PASSWORD_HASH_TABLE].delete visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client1.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'Logged In via password and webauthn' logout fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] webauthn_hash1 = webauthn_client1.get(challenge: challenge) fill_in 'webauthn_auth', :with=>webauthn_hash1.to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client2.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' logout fill_in 'Login', :with=>'foo@example.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] webauthn_hash2 = webauthn_client2.get(challenge: challenge) fill_in 'webauthn_auth', :with=>webauthn_hash2.to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been logged in' page.html.must_include 'Logged In via webauthn' visit '/webauthn-remove' choose "webauthn-remove-#{webauthn_hash1["rawId"]}" click_button 'Remove WebAuthn Authenticator' page.find('#notice_flash').text.must_equal "WebAuthn authenticator has been removed" page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' visit '/webauthn-remove' choose "webauthn-remove-#{webauthn_hash2["rawId"]}" click_button 'Remove WebAuthn Authenticator' page.find('#notice_flash').text.must_equal "WebAuthn authenticator has been removed" page.current_path.must_equal '/' page.html.must_include 'Not Logged In' end [true, false].each do |before| it "should allow webauthn login via json, when loading webauthn_login #{before ? "before" : "after"}" do rodauth do features = [:json, :webauthn_login] features.reverse! if before enable :logout, *features hmac_secret '123' end first_request = nil roda(:json_no_enable) do |r| first_request ||= r r.rodauth rodauth.authenticated_by || [''] end json_request.must_equal [200, ['']] json_login json_request.must_equal [200, ['password']] origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) res = json_request('/webauthn-setup', :password=>'0123456789') setup_json = res[1].delete("webauthn_setup") challenge = res[1].delete("webauthn_setup_challenge") challenge_hmac = res[1].delete("webauthn_setup_challenge_hmac") webauthn_hash = webauthn_client.create(challenge: setup_json['challenge']) res = json_request('/webauthn-setup', :password=>'0123456789', :webauthn_setup=>webauthn_hash, :webauthn_setup_challenge=>challenge, :webauthn_setup_challenge_hmac=>challenge_hmac) res.must_equal [200, {'success'=>'WebAuthn authentication is now setup'}] json_logout json_request.must_equal [200, ['']] res = json_request('/webauthn-login') res.must_equal [401, {"field-error"=>["login", "no matching login"], "error"=>"There was an error authenticating via WebAuthn"}] res = json_request('/webauthn-login', :login=>'foo@example.com') auth_json = res[1].delete("webauthn_auth") challenge = res[1].delete("webauthn_auth_challenge") challenge_hmac = res[1].delete("webauthn_auth_challenge_hmac") res.must_equal [422, {"field-error"=>["webauthn_auth", "invalid webauthn authentication param"], "error"=>"There was an error authenticating via WebAuthn"}] res = json_request('/webauthn-login', :login=>'foo@example.com', :webauthn_auth=>webauthn_client.get(challenge: auth_json['challenge']), :webauthn_auth_challenge=>challenge, :webauthn_auth_challenge_hmac=>challenge_hmac) res.must_equal [200, {'success'=>'You have been logged in'}] json_request.must_equal [200, ['webauthn']] end end it "should support webauthn login using internal requests" do rodauth do enable :webauthn_login, :internal_request hmac_secret '123' domain "example.com" end roda do |r| end webauthn_client = WebAuthn::FakeClient.new("https://example.com") invalid_webauthn_client = WebAuthn::FakeClient.new("https://example.com") setup_params = app.rodauth.webauthn_setup_params(account_login: 'foo@example.com') app.rodauth.webauthn_setup( account_login: 'foo@example.com', webauthn_setup: webauthn_client.create(challenge: setup_params[:webauthn_setup][:challenge]), webauthn_setup_challenge: setup_params[:webauthn_setup_challenge], webauthn_setup_challenge_hmac: setup_params[:webauthn_setup_challenge_hmac] ).must_be_nil proc { app.rodauth.webauthn_login_params }.must_raise Rodauth::InternalRequestError proc { app.rodauth.webauthn_login_params(login: 'bar@example.com') }.must_raise Rodauth::InternalRequestError auth_params = app.rodauth.webauthn_login_params(login: 'foo@example.com') proc do app.rodauth.webauthn_login( webauthn_auth: invalid_webauthn_client.create(challenge: auth_params[:webauthn_auth][:challenge]), webauthn_auth_challenge: auth_params[:webauthn_auth_challenge], webauthn_auth_challenge_hmac: auth_params[:webauthn_auth_challenge_hmac] ) end.must_raise Rodauth::InternalRequestError app.rodauth.webauthn_login( login: 'foo@example.com', webauthn_auth: webauthn_client.get(challenge: auth_params[:webauthn_auth][:challenge]), webauthn_auth_challenge: auth_params[:webauthn_auth_challenge], webauthn_auth_challenge_hmac: auth_params[:webauthn_auth_challenge_hmac] ).must_equal DB[:accounts].get(:id) auth_params = app.rodauth.webauthn_login_params(account_login: 'foo@example.com') app.rodauth.webauthn_login( account_login: 'foo@example.com', webauthn_auth: webauthn_client.get(challenge: auth_params[:webauthn_auth][:challenge]), webauthn_auth_challenge: auth_params[:webauthn_auth_challenge], webauthn_auth_challenge_hmac: auth_params[:webauthn_auth_challenge_hmac] ).must_equal DB[:accounts].get(:id) end end end jeremyevans-rodauth-b53f402/spec/webauthn_modify_email_spec.rb000066400000000000000000000044631515725514200246770ustar00rootroot00000000000000require_relative 'spec_helper' begin require 'webauthn/fake_client' rescue LoadError else describe 'Rodauth webauthn feature' do it "should email when a webauth authenticator is added or removed" do rodauth do enable :login, :logout, :webauthn_modify_email hmac_secret '123' two_factor_modifications_require_password? false webauthn_remove_redirect '/foo' end first_request = nil roda do |r| first_request ||= r r.rodauth r.get('foo'){view :content=>"WebAuthn Removed"} rodauth.require_authentication rodauth.require_two_factor_setup view :content=>"With WebAuthn" end login origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' email = email_sent email.subject.must_equal "WebAuthn Authenticator Added" email.body.to_s.must_equal <webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.current_path.must_equal '/' visit '/webauthn-remove' choose(/(?<=name="webauthn_remove" id=")webauthn-remove-[^"]*/.match(page.body)[0]) click_button 'Remove WebAuthn Authenticator' page.find('#notice_flash').text.must_equal "WebAuthn authenticator has been removed" email = email_sent email.subject.must_equal "WebAuthn Authenticator Removed" email.body.to_s.must_equal <"With WebAuthn" else view :content=>"Without WebAuthn" end end login page.html.must_include('Without WebAuthn') origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) valid_webauthn_client = WebAuthn::FakeClient.new(origin) bad_origin_client = WebAuthn::FakeClient.new(origin.sub('com', 'gov')) %w'/webauthn-auth /webauthn-remove'.each do |path| visit path page.find('#error_flash').text.must_equal 'This account has not been setup for WebAuthn authentication' page.current_path.must_equal '/webauthn-setup' end page.title.must_equal 'Setup WebAuthn Authentication' fill_in 'Password', :with=>'asdf' fill_in 'webauthn_setup', :with=>'{}' click_button 'Setup WebAuthn Authentication' page.find('#error_flash').text.must_equal 'Error setting up WebAuthn authentication' page.html.must_include 'invalid password' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' click_button 'Setup WebAuthn Authentication' page.find('#error_flash').text.must_equal 'Error setting up WebAuthn authentication' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json[0...-1] click_button 'Setup WebAuthn Authentication' page.find('#error_flash').text.must_equal 'Error setting up WebAuthn authentication' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge+'1').to_json click_button 'Setup WebAuthn Authentication' page.find('#error_flash').text.must_equal 'Error setting up WebAuthn authentication' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>bad_origin_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#error_flash').text.must_equal 'Error setting up WebAuthn authentication' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json hmac_secret = '321' click_button 'Setup WebAuthn Authentication' page.find('#error_flash').text.must_equal 'Error setting up WebAuthn authentication' hmac_secret = '123' visit page.current_path challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json hmac_secret = '321' hmac_old_secret = '333' click_button 'Setup WebAuthn Authentication' page.find('#error_flash').text.must_equal 'Error setting up WebAuthn authentication' hmac_secret = '123' visit page.current_path setup_path = page.current_path hmac_secret = '321' hmac_old_secret = '123' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' DB[:account_webauthn_keys].delete hmac_secret = '123' hmac_old_secret = nil visit setup_path challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' webauthn_hash = webauthn_client.create(challenge: challenge) fill_in 'webauthn_setup', :with=>webauthn_hash.to_json before_setup = lambda do DB[:account_webauthn_keys].insert(:account_id=>DB[:accounts].get(:id), :webauthn_id=>webauthn_hash["rawId"], :public_key=>'1', :sign_count=>1) end click_button 'Setup WebAuthn Authentication' page.find('#error_flash').text.must_equal 'Error setting up WebAuthn authentication' before_setup = nil challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>valid_webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With WebAuthn' logout login page.title.must_equal 'Authenticate Using WebAuthn' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal 'Error authenticating using WebAuthn' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json[0...-1] click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal 'Error authenticating using WebAuthn' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge+'1').to_json click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal 'Error authenticating using WebAuthn' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>bad_origin_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal 'Error authenticating using WebAuthn' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json hmac_secret = '321' click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal 'Error authenticating using WebAuthn' hmac_secret = '123' visit page.current_path challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json hmac_secret = '321' hmac_old_secret = '333' click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal 'Error authenticating using WebAuthn' hmac_secret = '123' visit page.current_path auth_path = page.current_path hmac_secret = '321' hmac_old_secret = '123' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>valid_webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' hmac_secret = '123' hmac_old_secret = nil logout login visit auth_path challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json sign_count = DB[:account_webauthn_keys].get(:sign_count) DB[:account_webauthn_keys].update(:sign_count=>sign_count + 10) click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal 'Error authenticating using WebAuthn' DB[:account_webauthn_keys].update(:sign_count=>sign_count) visit page.current_path challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>valid_webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.current_path.must_equal '/' page.html.must_include 'With WebAuthn' visit '/webauthn-remove' page.title.must_equal 'Remove WebAuthn Authenticator' choose(last_use_re.match(page.body)[0]) click_button 'Remove WebAuthn Authenticator' page.find('#error_flash').text.must_equal "Error removing WebAuthn authenticator" page.html.must_include 'invalid password' fill_in 'Password', :with=>'0123456789' click_button 'Remove WebAuthn Authenticator' page.find('#error_flash').text.must_equal "Error removing WebAuthn authenticator" page.html.must_include 'must select valid webauthn authenticator to remove' fill_in 'Password', :with=>'0123456789' choose(last_use_re.match(page.body)[0]) key_row = DB[:account_webauthn_keys].first before_remove = lambda do DB[:account_webauthn_keys].delete end click_button 'Remove WebAuthn Authenticator' page.find('#error_flash').text.must_equal "Error removing WebAuthn authenticator" before_remove = nil DB[:account_webauthn_keys].insert(key_row) visit page.current_path fill_in 'Password', :with=>'0123456789' choose(last_use_re.match(page.body)[0]) click_button 'Remove WebAuthn Authenticator' page.find('#notice_flash').text.must_equal "WebAuthn authenticator has been removed" page.current_path.must_equal '/' page.html.must_include 'Without WebAuthn' visit '/webauthn-auth-js' page.body.must_include File.binread("javascript/webauthn_auth.js") visit '/webauthn-setup-js' page.body.must_include File.binread("javascript/webauthn_setup.js") end it "should allow namespaced webauthn authentication without password requirements" do rodauth do enable :login, :logout, :webauthn, :jwt prefix "/auth" hmac_secret '123' two_factor_modifications_require_password? false end first_request = nil roda do |r| first_request ||= r r.on "auth" do r.rodauth end r.redirect '/auth/login' unless rodauth.logged_in? if rodauth.two_factor_authentication_setup? r.redirect '/auth/webauthn-auth' unless rodauth.authenticated? view :content=>"With WebAuthn" else view :content=>"Without WebAuthn" end end login page.html.must_include('Without WebAuthn') origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) visit '/auth/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' page.current_path.must_equal '/' page.html.must_include 'With WebAuthn' visit '/auth/logout' click_button 'Logout' login challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.current_path.must_equal '/' page.html.must_include 'With WebAuthn' visit '/auth/webauthn-remove' choose(last_use_re.match(page.body)[0]) click_button 'Remove WebAuthn Authenticator' page.find('#notice_flash').text.must_equal "WebAuthn authenticator has been removed" page.current_path.must_equal '/' page.html.must_include 'Without WebAuthn' end it "should remove webauthn data when closing accounts" do rodauth do enable :login, :webauthn, :close_account hmac_secret '123' modifications_require_password? false two_factor_modifications_require_password? false end first_request = nil roda do |r| first_request ||= r r.rodauth rodauth.require_authentication rodauth.require_two_factor_setup view :content=>"With WebAuthn" end login origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' DB[:account_webauthn_user_ids].wont_be_empty DB[:account_webauthn_keys].wont_be_empty visit '/close-account' click_button 'Close Account' DB[:account_webauthn_user_ids].must_be_empty DB[:account_webauthn_keys].must_be_empty end it "should handle registering and using multiple webauthn authenticators" do rodauth do enable :login, :logout, :webauthn hmac_secret '123' two_factor_modifications_require_password? false end first_request = nil roda do |r| first_request ||= r r.rodauth rodauth.require_authentication if rodauth.two_factor_authentication_setup? view :content=>"With WebAuthn" else view :content=>"Without WebAuthn" end end login origin = first_request.base_url webauthn_client1 = WebAuthn::FakeClient.new(origin) webauthn_client2 = WebAuthn::FakeClient.new(origin) visit '/multifactor-manage' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] webauthn_hash = webauthn_client1.create(challenge: challenge) fill_in 'webauthn_setup', :with=>webauthn_hash.to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' visit '/multifactor-manage' click_link 'Setup WebAuthn Authentication' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client2.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' logout login challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client1.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' logout login challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client2.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' visit '/multifactor-manage' click_link 'Remove WebAuthn Authenticator' choose "webauthn-remove-#{webauthn_hash["rawId"]}" click_button 'Remove WebAuthn Authenticator' page.find('#notice_flash').text.must_equal "WebAuthn authenticator has been removed" page.current_path.must_equal '/' page.html.must_include 'With WebAuthn' logout login challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client1.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal 'Error authenticating using WebAuthn' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client2.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' visit '/multifactor-manage' click_link 'Remove WebAuthn Authenticator' choose(last_use_re.match(page.body)[0]) click_button 'Remove WebAuthn Authenticator' page.find('#notice_flash').text.must_equal "WebAuthn authenticator has been removed" page.current_path.must_equal '/' page.html.must_include 'Without WebAuthn' end it "should handle webauthn authentication webauth_rp_id different than origin" do rp_id = 'foo.com' rodauth do enable :login, :logout, :webauthn hmac_secret '123' two_factor_modifications_require_password? false webauthn_rp_id rp_id end first_request = nil roda do |r| first_request ||= r r.rodauth rodauth.require_authentication rodauth.require_two_factor_setup view :content=>"With WebAuthn" end login origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge, rp_id: rp_id).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' logout login challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge, rp_id: rp_id).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.current_path.must_equal '/' page.html.must_include 'With WebAuthn' end it "should handle webauthn authentication with invalid sign counts if configured" do default_sign_count = false rodauth do enable :login, :logout, :webauthn hmac_secret '123' two_factor_modifications_require_password? false handle_webauthn_sign_count_verification_error do super() if default_sign_count end end first_request = nil roda do |r| first_request ||= r r.rodauth rodauth.require_authentication rodauth.require_two_factor_setup view :content=>"With WebAuthn" end login origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' logout login challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json DB[:account_webauthn_keys].update(:sign_count=>Sequel[:sign_count] + 10) click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' page.current_path.must_equal '/' page.html.must_include 'With WebAuthn' logout login default_sign_count = true challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json DB[:account_webauthn_keys].update(:sign_count=>Sequel[:sign_count] + 10) click_button 'Authenticate Using WebAuthn' page.find('#error_flash').text.must_equal 'Error authenticating using WebAuthn' end it "should not display links for routes that were disabled" do webauthn_auth_route = 'webauthn-route' webauthn_setup_route = 'webauthn-setup' webauthn_remove_route = 'webauthn-remove' first_request = nil rodauth do enable :login, :logout, :webauthn hmac_secret '123' webauthn_auth_route { webauthn_auth_route } webauthn_setup_route { webauthn_setup_route } webauthn_remove_route { webauthn_remove_route } end roda do |r| first_request = r r.rodauth r.get('auth-links') { rodauth.two_factor_auth_links.map { |link| link[1] }.to_s } r.get('setup-links') { rodauth.two_factor_setup_links.map { |link| link[1] }.to_s } r.get('remove-links') { rodauth.two_factor_remove_links.map { |link| link[1] }.to_s } r.root{view :content=>"Home"} end login origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) webauthn_setup_route = nil visit '/setup-links' page.html.must_equal '[]' webauthn_setup_route = 'webauthn-setup' visit '/setup-links' page.html.must_equal '["/webauthn-setup"]' visit '/webauthn-setup' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' logout login webauthn_auth_route = nil visit '/auth-links' page.html.must_equal '[]' webauthn_auth_route = "webauthn-auth" visit '/auth-links' page.html.must_equal '["/webauthn-auth"]' webauthn_remove_route = nil visit '/remove-links' page.html.must_equal '[]' webauthn_remove_route = "webauthn-remove" visit '/remove-links' page.html.must_equal '["/webauthn-remove"]' end [:jwt, :json].each do |json| it "should allow webauthn authentication via #{json}" do rodauth do enable :login, :logout, :webauthn hmac_secret '123' end first_request = nil roda(json) do |r| first_request ||= r r.rodauth r.post('rp-id'){[rodauth.webauthn_rp_id]} if rodauth.logged_in? if rodauth.two_factor_authentication_setup? if rodauth.authenticated? [1] else [2] end else [3] end else [4] end end json_request.must_equal [200, [4]] json_login json_request.must_equal [200, [3]] json_request('/rp-id', :headers=>{"HTTP_HOST" => "example.com:1234"}).must_equal [200, ['example.com']] origin = first_request.base_url bad_client = WebAuthn::FakeClient.new(origin) webauthn_client1 = WebAuthn::FakeClient.new(origin) webauthn_client2 = WebAuthn::FakeClient.new(origin) %w'/webauthn-auth /webauthn-remove'.each do |path| json_request(path).must_equal [403, {'reason'=>'webauthn_not_setup', 'error'=>'This account has not been setup for WebAuthn authentication'}] end res = json_request('/webauthn-setup', :password=>'0123456789') setup_json = res[1].delete("webauthn_setup") challenge = res[1].delete("webauthn_setup_challenge") challenge_hmac = res[1].delete("webauthn_setup_challenge_hmac") res.must_equal [422, {'reason'=>"invalid_webauthn_setup_param",'error'=>'Error setting up WebAuthn authentication', "field-error"=>["webauthn_setup", 'invalid webauthn setup param']}] res = json_request('/webauthn-setup', :password=>'123456', :webauthn_setup=>'{}') res.must_equal [401, {'reason'=>"invalid_password",'error'=>'Error setting up WebAuthn authentication', "field-error"=>["password", 'invalid password']}] res = json_request('/webauthn-setup', :password=>'0123456789', :webauthn_setup=>bad_client.create(challenge: setup_json['challenge']), :webauthn_setup_challenge=>challenge+'1', :webauthn_setup_challenge_hmac=>challenge_hmac) res.must_equal [422, {'reason'=>"invalid_webauthn_setup_param",'error'=>'Error setting up WebAuthn authentication', "field-error"=>["webauthn_setup", 'invalid webauthn setup param']}] res = json_request('/webauthn-setup', :password=>'0123456789', :webauthn_setup=>bad_client.create(challenge: setup_json['challenge'] + '1'), :webauthn_setup_challenge=>challenge, :webauthn_setup_challenge_hmac=>challenge_hmac) res.must_equal [422, {'reason'=>"invalid_webauthn_setup_param",'error'=>'Error setting up WebAuthn authentication', "field-error"=>["webauthn_setup", 'invalid webauthn setup param']}] webauthn_hash1 = webauthn_client1.create(challenge: setup_json['challenge']) res = json_request('/webauthn-setup', :password=>'0123456789', :webauthn_setup=>webauthn_hash1, :webauthn_setup_challenge=>challenge, :webauthn_setup_challenge_hmac=>challenge_hmac) res.must_equal [200, {'success'=>'WebAuthn authentication is now setup'}] res = json_request('/webauthn-setup', :password=>'0123456789') setup_json = res[1].delete("webauthn_setup") challenge = res[1].delete("webauthn_setup_challenge") challenge_hmac = res[1].delete("webauthn_setup_challenge_hmac") res.must_equal [422, {'reason'=>"invalid_webauthn_setup_param",'error'=>'Error setting up WebAuthn authentication', "field-error"=>["webauthn_setup", 'invalid webauthn setup param']}] webauthn_hash2 = webauthn_client2.create(challenge: setup_json['challenge']) res = json_request('/webauthn-setup', :password=>'0123456789', :webauthn_setup=>webauthn_hash2, :webauthn_setup_challenge=>challenge, :webauthn_setup_challenge_hmac=>challenge_hmac) res.must_equal [200, {'success'=>'WebAuthn authentication is now setup'}] json_request.must_equal [200, [1]] json_logout json_login json_request.must_equal [200, [2]] res = json_request('/webauthn-auth') auth_json = res[1].delete("webauthn_auth") challenge = res[1].delete("webauthn_auth_challenge") challenge_hmac = res[1].delete("webauthn_auth_challenge_hmac") res.must_equal [422, {'reason'=>"invalid_webauthn_auth_param","field-error"=>["webauthn_auth", "invalid webauthn authentication param"], "error"=>"Error authenticating using WebAuthn"}] res = json_request('/webauthn-auth', :webauthn_auth=>webauthn_client1.get(challenge: auth_json['challenge']), :webauthn_auth_challenge=>challenge, :webauthn_auth_challenge_hmac=>challenge_hmac) res.must_equal [200, {'success'=>'You have been multifactor authenticated'}] json_request.must_equal [200, [1]] json_logout json_login res = json_request('/webauthn-auth') auth_json = res[1].delete("webauthn_auth") challenge = res[1].delete("webauthn_auth_challenge") challenge_hmac = res[1].delete("webauthn_auth_challenge_hmac") res.must_equal [422, {'reason'=>"invalid_webauthn_auth_param","field-error"=>["webauthn_auth", "invalid webauthn authentication param"], "error"=>"Error authenticating using WebAuthn"}] res = json_request('/webauthn-auth', :webauthn_auth=>webauthn_client2.get(challenge: auth_json['challenge']), :webauthn_auth_challenge=>challenge, :webauthn_auth_challenge_hmac=>challenge_hmac) res.must_equal [200, {'success'=>'You have been multifactor authenticated'}] json_request.must_equal [200, [1]] res = json_request('/webauthn-remove', :password=>'0123456789') remove_ids = res[1].delete("webauthn_remove") remove_ids[webauthn_hash1['rawId']].must_include(Time.now.strftime('%F')) remove_ids[webauthn_hash2['rawId']].must_include(Time.now.strftime('%F')) remove_ids.length.must_equal 2 res.must_equal [422, {'reason'=>"invalid_webauthn_remove_param","field-error"=>["webauthn_remove", "must select valid webauthn authenticator to remove"], "error"=>"Error removing WebAuthn authenticator"}] res = json_request('/webauthn-remove', :password=>'012345678', :webauthn_remove=>'1') res[1].delete("webauthn_remove").must_be_nil res.must_equal [401, {'reason'=>"invalid_password","field-error"=>["password", "invalid password"], "error"=>"Error removing WebAuthn authenticator"}] res = json_request('/webauthn-remove', :password=>'0123456789', :webauthn_remove=>webauthn_hash1['rawId']) res.must_equal [200, {'success'=>'WebAuthn authenticator has been removed'}] json_request.must_equal [200, [1]] res = json_request('/webauthn-remove', :password=>'0123456789', :webauthn_remove=>webauthn_hash2['rawId']) res.must_equal [200, {'success'=>'WebAuthn authenticator has been removed'}] json_request.must_equal [200, [3]] end end it "should support setup, authentication and removal using internal requests" do rodauth do enable :webauthn, :internal_request hmac_secret '123' domain "example.com" end roda do |r| end webauthn_client = WebAuthn::FakeClient.new("https://example.com") invalid_webauthn_client = WebAuthn::FakeClient.new("https://example.com") proc { app.rodauth.webauthn_setup_params }.must_raise Rodauth::InternalRequestError setup_params = app.rodauth.webauthn_setup_params(account_login: 'foo@example.com') proc do app.rodauth.webauthn_setup( webauthn_setup: invalid_webauthn_client.create(challenge: setup_params[:webauthn_setup][:challenge]), webauthn_setup_challenge: setup_params[:webauthn_setup_challenge], webauthn_setup_challenge_hmac: setup_params[:webauthn_setup_challenge_hmac] ) end.must_raise Rodauth::InternalRequestError app.rodauth.webauthn_setup( account_login: 'foo@example.com', webauthn_setup: webauthn_client.create(challenge: setup_params[:webauthn_setup][:challenge]), webauthn_setup_challenge: setup_params[:webauthn_setup_challenge], webauthn_setup_challenge_hmac: setup_params[:webauthn_setup_challenge_hmac] ).must_be_nil proc { app.rodauth.webauthn_auth_params }.must_raise Rodauth::InternalRequestError auth_params = app.rodauth.webauthn_auth_params(account_login: 'foo@example.com') proc do app.rodauth.webauthn_auth( webauthn_auth: invalid_webauthn_client.get(challenge: auth_params[:webauthn_auth][:challenge]), webauthn_auth_challenge: auth_params[:webauthn_auth_challenge], webauthn_auth_challenge_hmac: auth_params[:webauthn_auth_challenge_hmac] ) end.must_raise Rodauth::InternalRequestError app.rodauth.webauthn_auth( account_login: 'foo@example.com', webauthn_auth: webauthn_client.get(challenge: auth_params[:webauthn_auth][:challenge]), webauthn_auth_challenge: auth_params[:webauthn_auth_challenge], webauthn_auth_challenge_hmac: auth_params[:webauthn_auth_challenge_hmac] ).must_be_nil credential_id = DB[:account_webauthn_keys].get(:webauthn_id) proc do app.rodauth.webauthn_remove(webauthn_remove: credential_id) end.must_raise Rodauth::InternalRequestError app.rodauth.webauthn_remove( account_login: 'foo@example.com', webauthn_remove: credential_id ).must_be_nil end end end jeremyevans-rodauth-b53f402/spec/webauthn_verify_account_spec.rb000066400000000000000000000160411515725514200252540ustar00rootroot00000000000000require_relative 'spec_helper' begin require 'webauthn/fake_client' rescue LoadError else describe 'Rodauth webauthn_verify_account feature' do it "should support setting up webauthn when verifying accounts" do rodauth do enable :webauthn_verify_account, :logout, :webauthn_login, :verify_login_change hmac_secret '123' verify_login_change_autologin? true end first_request = nil roda do |r| first_request ||= r r.rodauth r.root{view :content=>rodauth.authenticated_by ? "Logged In via #{rodauth.authenticated_by.join(' and ')}" : 'Not Logged In'} end visit '/create-account' origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) page.html.wont_include 'Password' fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.html.must_include 'Not Logged In' page.current_path.must_equal '/' link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') visit link page.title.must_equal 'Setup WebAuthn Authentication' page.html.wont_include 'Password' click_button 'Setup WebAuthn Authentication' page.find('#error_flash').text.must_equal 'Unable to verify account' challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal "Your account has been verified" page.html.must_include 'Logged In via webauthn' page.current_path.must_equal '/' logout visit '/login' fill_in 'Login', :with=>'foo@example2.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' visit '/change-login' fill_in 'Login', :with=>'foo@example3.com' click_button 'Change Login' link = email_link(/(\/verify-login-change\?key=.+)$/, 'foo@example3.com') page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your login change" logout visit link page.title.must_equal 'Verify Login Change' click_button 'Verify Login Change' page.find('#notice_flash').text.must_equal "Your login change has been verified" page.html.must_include 'Logged In via autologin' logout visit '/login' fill_in 'Login', :with=>'foo@example3.com' click_button 'Login' challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json click_button 'Authenticate Using WebAuthn' page.find('#notice_flash').text.must_equal 'You have been logged in' page.current_path.must_equal '/' page.html.must_include 'Logged In via webauthn' end it "should support closing accounts without a password" do rodauth do enable :webauthn_verify_account, :close_account hmac_secret '123' end first_request = nil roda do |r| first_request ||= r r.rodauth r.root{view :content=>rodauth.authenticated_by ? "Logged In via #{rodauth.authenticated_by.join(' and ')}" : 'Not Logged In'} end visit '/create-account' origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) fill_in 'Login', :with=>'foo@example2.com' click_button 'Create Account' page.find('#notice_flash').text.must_equal "An email has been sent to you with a link to verify your account" page.html.must_include 'Not Logged In' page.current_path.must_equal '/' link = email_link(/(\/verify-account\?key=.+)$/, 'foo@example2.com') visit link challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json click_button 'Setup WebAuthn Authentication' page.find('#notice_flash').text.must_equal "Your account has been verified" page.html.must_include 'Logged In via webauthn' page.current_path.must_equal '/' visit '/close-account' click_button 'Close Account' page.find('#notice_flash').text.must_equal "Your account has been closed" page.current_path.must_equal '/' page.html.must_include 'Not Logged In' end [:jwt, :json].each do |json| it "should allow webauthn setup when verifying accounts via #{json}" do rodauth do enable :webauthn_verify_account, :logout, :webauthn_login verify_account_email_body{verify_account_email_link} hmac_secret '123' end first_request = nil roda(json) do |r| first_request ||= r r.rodauth rodauth.authenticated_by || [''] end res = json_request('/create-account', :login=>'foo@example2.com', :password=>'0123456789', "password-confirm"=>'0123456789') res.must_equal [200, {'success'=>"An email has been sent to you with a link to verify your account"}] link = email_link(/key=.+$/, 'foo@example2.com') origin = first_request.base_url webauthn_client = WebAuthn::FakeClient.new(origin) res = json_request('/verify-account', :key=>link[4..-1]) setup_json = res[1].delete("webauthn_setup") challenge = res[1].delete("webauthn_setup_challenge") challenge_hmac = res[1].delete("webauthn_setup_challenge_hmac") res.must_equal [422, {'reason'=>"invalid_webauthn_setup_param","field-error"=>["webauthn_setup", "invalid webauthn setup param"], "error"=>"Unable to verify account"}] res = json_request('/verify-account', :key=>link[4..-1], :webauthn_setup=>webauthn_client.create(challenge: setup_json['challenge']), :webauthn_setup_challenge=>challenge, :webauthn_setup_challenge_hmac=>challenge_hmac) res.must_equal [200, {"success"=>"Your account has been verified"}] res = json_request('/webauthn-login', :login=>'foo@example2.com') auth_json = res[1].delete("webauthn_auth") challenge = res[1].delete("webauthn_auth_challenge") challenge_hmac = res[1].delete("webauthn_auth_challenge_hmac") res.must_equal [422, {'reason'=>"invalid_webauthn_auth_param","field-error"=>["webauthn_auth", "invalid webauthn authentication param"], "error"=>"There was an error authenticating via WebAuthn"}] res = json_request('/webauthn-login', :login=>'foo@example2.com', :webauthn_auth=>webauthn_client.get(challenge: auth_json['challenge']), :webauthn_auth_challenge=>challenge, :webauthn_auth_challenge_hmac=>challenge_hmac) res.must_equal [200, {'success'=>'You have been logged in'}] json_request.must_equal [200, ['webauthn']] end end end end jeremyevans-rodauth-b53f402/spec/words000066400000000000000000000000431515725514200200540ustar00rootroot00000000000000password least little lasts nibble jeremyevans-rodauth-b53f402/templates/000077500000000000000000000000001515725514200200425ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/templates/add-recovery-codes.str000066400000000000000000000003041515725514200242500ustar00rootroot00000000000000
#{rodauth.recovery_codes.map{|s| h s}.join("\n\n")}
#{"#{rodauth.add_recovery_codes_heading}#{rodauth.render('recovery-codes')}" if rodauth.can_add_recovery_codes?} jeremyevans-rodauth-b53f402/templates/button.str000066400000000000000000000002571515725514200221130ustar00rootroot00000000000000
jeremyevans-rodauth-b53f402/templates/change-login.str000066400000000000000000000006141515725514200231300ustar00rootroot00000000000000
#{rodauth.change_login_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('login-field')} #{rodauth.render('login-confirm-field') if rodauth.require_login_confirmation?} #{rodauth.render('password-field') if rodauth.change_login_requires_password?} #{rodauth.button(rodauth.change_login_button)}
jeremyevans-rodauth-b53f402/templates/change-password.str000066400000000000000000000012401515725514200236560ustar00rootroot00000000000000
#{rodauth.change_password_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field') if rodauth.change_password_requires_password?}
#{rodauth.input_field_string(rodauth.new_password_param, 'new-password', :type => 'password', :autocomplete=>"new-password")}
#{rodauth.render('password-confirm-field') if rodauth.require_password_confirmation?} #{rodauth.button(rodauth.change_password_button)}
jeremyevans-rodauth-b53f402/templates/close-account.str000066400000000000000000000004651515725514200233400ustar00rootroot00000000000000
#{rodauth.close_account_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field') if rodauth.close_account_requires_password?} #{rodauth.button(rodauth.close_account_button, :class=>'btn btn-danger')}
jeremyevans-rodauth-b53f402/templates/confirm-password.str000066400000000000000000000003701515725514200240710ustar00rootroot00000000000000
#{rodauth.confirm_password_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field')} #{rodauth.button(rodauth.confirm_password_button)}
jeremyevans-rodauth-b53f402/templates/create-account.str000066400000000000000000000010171515725514200234700ustar00rootroot00000000000000
#{rodauth.create_account_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('login-field')} #{rodauth.render('login-confirm-field') if rodauth.require_login_confirmation?} #{rodauth.render('password-field') if rodauth.create_account_set_password?} #{rodauth.render('password-confirm-field') if rodauth.create_account_set_password? && rodauth.require_password_confirmation?} #{rodauth.button(rodauth.create_account_button)}
jeremyevans-rodauth-b53f402/templates/email-auth-email.str000066400000000000000000000003671515725514200237150ustar00rootroot00000000000000Someone has requested a login link for the account with this email address. If you did not request a login link, please ignore this message. If you requested a login link, please go to #{rodauth.email_auth_email_link} to login to this account. jeremyevans-rodauth-b53f402/templates/email-auth-request-form.str000066400000000000000000000005051515725514200252510ustar00rootroot00000000000000
#{rodauth.email_auth_request_additional_form_tags} #{rodauth.csrf_tag(rodauth.email_auth_request_path)} #{rodauth.login_hidden_field} #{rodauth.button(rodauth.email_auth_request_button)}
jeremyevans-rodauth-b53f402/templates/email-auth.str000066400000000000000000000002731515725514200226240ustar00rootroot00000000000000
#{rodauth.email_auth_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.button(rodauth.login_button)}
jeremyevans-rodauth-b53f402/templates/global-logout-field.str000066400000000000000000000005061515725514200244250ustar00rootroot00000000000000
jeremyevans-rodauth-b53f402/templates/login-confirm-field.str000066400000000000000000000005111515725514200244150ustar00rootroot00000000000000
#{rodauth.input_field_string(rodauth.login_confirm_param, 'login-confirm', :type=>rodauth.login_input_type, :autocomplete=>rodauth.login_uses_email? ? "email" : "on")}
jeremyevans-rodauth-b53f402/templates/login-display.str000066400000000000000000000003641515725514200233520ustar00rootroot00000000000000
#{rodauth.login_hidden_field}
#{h rodauth.param(rodauth.login_param)}
jeremyevans-rodauth-b53f402/templates/login-field.str000066400000000000000000000004451515725514200227700ustar00rootroot00000000000000
#{rodauth.input_field_string(rodauth.login_param, 'login', :type=>rodauth.login_input_type, :autocomplete=>rodauth.login_field_autocomplete_value)}
jeremyevans-rodauth-b53f402/templates/login-form-footer.str000066400000000000000000000003411515725514200241370ustar00rootroot00000000000000#{rodauth.login_form_footer_links_heading} jeremyevans-rodauth-b53f402/templates/login-form.str000066400000000000000000000005561515725514200226530ustar00rootroot00000000000000
#{rodauth.login_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.skip_login_field_on_login? ? rodauth.render('login-display') : rodauth.render('login-field')} #{rodauth.render('password-field') unless rodauth.skip_password_field_on_login?} #{rodauth.button(rodauth.login_button)}
jeremyevans-rodauth-b53f402/templates/login.str000066400000000000000000000001321515725514200217000ustar00rootroot00000000000000#{rodauth.login_form_header} #{rodauth.render('login-form')} #{rodauth.login_form_footer} jeremyevans-rodauth-b53f402/templates/logout.str000066400000000000000000000003171515725514200221060ustar00rootroot00000000000000
#{rodauth.logout_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.button(rodauth.logout_button, :class=>'btn btn-warning')}
jeremyevans-rodauth-b53f402/templates/multi-phase-login.str000066400000000000000000000001441515725514200241310ustar00rootroot00000000000000#{rodauth.login_form_header} #{rodauth.render_multi_phase_login_forms} #{rodauth.login_form_footer} jeremyevans-rodauth-b53f402/templates/otp-auth-code-field.str000066400000000000000000000005431515725514200243300ustar00rootroot00000000000000
#{rodauth.input_field_string(rodauth.otp_auth_param, 'otp-auth-code', :value=>'', :autocomplete=>"off", :inputmode=>'numeric')}
jeremyevans-rodauth-b53f402/templates/otp-auth.str000066400000000000000000000004061515725514200223350ustar00rootroot00000000000000
#{rodauth.otp_auth_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('otp-auth-code-field')} #{rodauth.button(rodauth.otp_auth_button)}
#{rodauth.otp_auth_form_footer} jeremyevans-rodauth-b53f402/templates/otp-disable.str000066400000000000000000000004721515725514200230020ustar00rootroot00000000000000
#{rodauth.otp_disable_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field') if rodauth.two_factor_modifications_require_password?} #{rodauth.button(rodauth.otp_disable_button, :class=>'btn btn-warning')}
jeremyevans-rodauth-b53f402/templates/otp-disabled-email.str000066400000000000000000000001531515725514200242270ustar00rootroot00000000000000Someone (hopefully you) has disabled TOTP authentication for the account associated to this email address. jeremyevans-rodauth-b53f402/templates/otp-locked-out-email.str000066400000000000000000000007331515725514200245320ustar00rootroot00000000000000TOTP authentication has been locked out on your account due to too many consecutive authentication failures. You can attempt to unlock TOTP authentication for your account by consecutively authenticating via TOTP multiple times. If you did not initiate the TOTP authentication failures that caused TOTP authentication to be locked out, that means someone already has partial access to your account, but is unable to use TOTP authentication to fully authenticate themselves. jeremyevans-rodauth-b53f402/templates/otp-setup-email.str000066400000000000000000000001501515725514200236150ustar00rootroot00000000000000Someone (hopefully you) has setup TOTP authentication for the account associated to this email address. jeremyevans-rodauth-b53f402/templates/otp-setup.str000066400000000000000000000017401515725514200225360ustar00rootroot00000000000000
#{rodauth.otp_setup_additional_form_tags} #{"" if rodauth.otp_keys_use_hmac?} #{rodauth.csrf_tag}

#{rodauth.otp_secret_label}: #{rodauth.otp_user_key}

#{rodauth.otp_provisioning_uri_label}: #{rodauth.otp_provisioning_uri}

#{rodauth.otp_qr_code}

#{rodauth.render('password-field') if rodauth.two_factor_modifications_require_password?} #{rodauth.render('otp-auth-code-field')} #{rodauth.button(rodauth.otp_setup_button)}
jeremyevans-rodauth-b53f402/templates/otp-unlock-failed-email.str000066400000000000000000000006211515725514200251750ustar00rootroot00000000000000Someone (hopefully you) attempted to unlock TOTP authentication for the account associated to this email address, but failed as the authentication code submitted was not correct. If you did not initiate the TOTP authentication failure that generated this email, that means someone already has partial access to your account, but is unable to use TOTP authentication to fully authenticate themselves. jeremyevans-rodauth-b53f402/templates/otp-unlock-not-available.str000066400000000000000000000006111515725514200254010ustar00rootroot00000000000000

#{rodauth.otp_unlock_consecutive_successes_label}: #{rodauth.otp_unlock_num_successes}

#{rodauth.otp_unlock_required_consecutive_successes_label}: #{rodauth.otp_unlock_auths_required}

#{rodauth.otp_unlock_next_auth_attempt_label}: #{rodauth.otp_unlock_next_auth_attempt_after.strftime(rodauth.strftime_format)}

#{rodauth.otp_unlock_next_auth_attempt_refresh_label}

jeremyevans-rodauth-b53f402/templates/otp-unlock.str000066400000000000000000000011221515725514200226630ustar00rootroot00000000000000
#{rodauth.otp_unlock_additional_form_tags} #{rodauth.csrf_tag}

#{rodauth.otp_unlock_consecutive_successes_label}: #{rodauth.otp_unlock_num_successes}

#{rodauth.otp_unlock_required_consecutive_successes_label}: #{rodauth.otp_unlock_auths_required}

#{rodauth.otp_unlock_next_auth_deadline_label}: #{rodauth.otp_unlock_deadline.strftime(rodauth.strftime_format)}

#{rodauth.render('otp-auth-code-field')} #{rodauth.button(rodauth.otp_unlock_button)}
#{rodauth.otp_unlock_form_footer} jeremyevans-rodauth-b53f402/templates/otp-unlocked-email.str000066400000000000000000000001531515725514200242640ustar00rootroot00000000000000Someone (hopefully you) has unlocked TOTP authentication for the account associated to this email address. jeremyevans-rodauth-b53f402/templates/password-changed-email.str000066400000000000000000000001431515725514200251100ustar00rootroot00000000000000Someone (hopefully you) has changed the password for the account associated to this email address. jeremyevans-rodauth-b53f402/templates/password-confirm-field.str000066400000000000000000000004551515725514200251560ustar00rootroot00000000000000
#{rodauth.input_field_string(rodauth.password_confirm_param, 'password-confirm', :type => 'password', :autocomplete=>'new-password')}
jeremyevans-rodauth-b53f402/templates/password-field.str000066400000000000000000000004501515725514200235160ustar00rootroot00000000000000
#{rodauth.input_field_string(rodauth.password_param, 'password', :type => 'password', :autocomplete=>rodauth.password_field_autocomplete_value)}
jeremyevans-rodauth-b53f402/templates/recovery-auth.str000066400000000000000000000007441515725514200233760ustar00rootroot00000000000000
#{rodauth.recovery_auth_additional_form_tags} #{rodauth.csrf_tag}
#{rodauth.input_field_string(rodauth.recovery_codes_param, 'recovery-code', :value => '', :autocomplete=>'off')}
#{rodauth.button(rodauth.recovery_auth_button)}
jeremyevans-rodauth-b53f402/templates/recovery-codes.str000066400000000000000000000006321515725514200235260ustar00rootroot00000000000000
#{rodauth.recovery_codes_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field') if rodauth.two_factor_modifications_require_password?} #{rodauth.button(rodauth.recovery_codes_button || rodauth.view_recovery_codes_button, :name=>(rodauth.add_recovery_codes_param if rodauth.recovery_codes_button))}
jeremyevans-rodauth-b53f402/templates/remember.str000066400000000000000000000022061515725514200223720ustar00rootroot00000000000000
#{rodauth.remember_additional_form_tags} #{rodauth.csrf_tag}
#{rodauth.button(rodauth.remember_button)}
jeremyevans-rodauth-b53f402/templates/reset-password-email.str000066400000000000000000000004241515725514200246430ustar00rootroot00000000000000Someone has requested a password reset for the account with this email address. If you did not request a password reset, please ignore this message. If you requested a password reset, please go to #{rodauth.reset_password_email_link} to reset the password for the account. jeremyevans-rodauth-b53f402/templates/reset-password-notify-email.str000066400000000000000000000001411515725514200261450ustar00rootroot00000000000000Someone (hopefully you) has reset the password for the account associated to this email address. jeremyevans-rodauth-b53f402/templates/reset-password-request.str000066400000000000000000000007771515725514200252570ustar00rootroot00000000000000
#{rodauth.reset_password_request_additional_form_tags} #{rodauth.csrf_tag(rodauth.reset_password_request_path)} #{rodauth.reset_password_explanatory_text} #{rodauth.param_or_nil(rodauth.login_param) && !rodauth.field_error(rodauth.login_param) ? rodauth.login_hidden_field : rodauth.render('login-field')} #{rodauth.button(rodauth.reset_password_request_button)}
jeremyevans-rodauth-b53f402/templates/reset-password.str000066400000000000000000000005121515725514200235540ustar00rootroot00000000000000
#{rodauth.reset_password_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field')} #{rodauth.render('password-confirm-field') if rodauth.require_password_confirmation?} #{rodauth.button(rodauth.reset_password_button)}
jeremyevans-rodauth-b53f402/templates/sms-auth.str000066400000000000000000000003401515725514200223320ustar00rootroot00000000000000
#{rodauth.sms_auth_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('sms-code-field')} #{rodauth.button(rodauth.sms_auth_button)}
jeremyevans-rodauth-b53f402/templates/sms-code-field.str000066400000000000000000000005451515725514200233730ustar00rootroot00000000000000
#{rodauth.input_field_string(rodauth.sms_code_param, 'sms-code', :value => '', :autocomplete=>'one-time-code', :inputmode=>'numeric')}
jeremyevans-rodauth-b53f402/templates/sms-confirm.str000066400000000000000000000003511515725514200230300ustar00rootroot00000000000000
#{rodauth.sms_confirm_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('sms-code-field')} #{rodauth.button(rodauth.sms_confirm_button)}
jeremyevans-rodauth-b53f402/templates/sms-disable.str000066400000000000000000000004371515725514200230030ustar00rootroot00000000000000
#{rodauth.sms_disable_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field') if rodauth.two_factor_modifications_require_password?} #{rodauth.button(rodauth.sms_disable_button)}
jeremyevans-rodauth-b53f402/templates/sms-request.str000066400000000000000000000003031515725514200230600ustar00rootroot00000000000000
#{rodauth.sms_request_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.button(rodauth.sms_request_button)}
jeremyevans-rodauth-b53f402/templates/sms-setup.str000066400000000000000000000012101515725514200225260ustar00rootroot00000000000000
#{rodauth.sms_setup_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field') if rodauth.two_factor_modifications_require_password?}
#{rodauth.input_field_string(rodauth.sms_phone_param, 'sms-phone', :type=>rodauth.sms_phone_input_type, :autocomplete=>'tel')}
#{rodauth.button(rodauth.sms_setup_button)}
jeremyevans-rodauth-b53f402/templates/two-factor-auth.str000066400000000000000000000002611515725514200236170ustar00rootroot00000000000000 jeremyevans-rodauth-b53f402/templates/two-factor-disable.str000066400000000000000000000004651515725514200242670ustar00rootroot00000000000000
#{rodauth.two_factor_disable_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field') if rodauth.two_factor_modifications_require_password?} #{rodauth.button(rodauth.two_factor_disable_button)}
jeremyevans-rodauth-b53f402/templates/two-factor-manage.str000066400000000000000000000012661515725514200241140ustar00rootroot00000000000000#{rodauth.two_factor_setup_heading unless rodauth.two_factor_setup_links.empty?} #{rodauth.two_factor_remove_heading unless rodauth.two_factor_remove_links.empty?} jeremyevans-rodauth-b53f402/templates/unlock-account-email.str000066400000000000000000000004221515725514200246040ustar00rootroot00000000000000Someone has requested that the account with this email be unlocked. If you did not request the unlocking of this account, please ignore this message. If you requested the unlocking of this account, please go to #{rodauth.unlock_account_email_link} to unlock this account. jeremyevans-rodauth-b53f402/templates/unlock-account-request.str000066400000000000000000000006161515725514200252120ustar00rootroot00000000000000
#{rodauth.unlock_account_request_additional_form_tags} #{rodauth.csrf_tag(rodauth.unlock_account_request_path)} #{rodauth.login_hidden_field} #{rodauth.unlock_account_request_explanatory_text} #{rodauth.button(rodauth.unlock_account_request_button)}
jeremyevans-rodauth-b53f402/templates/unlock-account.str000066400000000000000000000005141515725514200235210ustar00rootroot00000000000000
#{rodauth.unlock_account_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.unlock_account_explanatory_text} #{rodauth.render('password-field') if rodauth.unlock_account_requires_password?} #{rodauth.button(rodauth.unlock_account_button)}
jeremyevans-rodauth-b53f402/templates/verify-account-email.str000066400000000000000000000003411515725514200246150ustar00rootroot00000000000000Someone has created an account with this email address. If you did not create this account, please ignore this message. If you created this account, please go to #{rodauth.verify_account_email_link} to verify the account. jeremyevans-rodauth-b53f402/templates/verify-account-resend.str000066400000000000000000000007241515725514200250130ustar00rootroot00000000000000
#{rodauth.verify_account_resend_additional_form_tags} #{rodauth.csrf_tag(rodauth.verify_account_resend_path)} #{rodauth.verify_account_resend_explanatory_text} #{rodauth.param_or_nil(rodauth.login_param) ? rodauth.login_hidden_field : rodauth.render('login-field')} #{rodauth.button(rodauth.verify_account_resend_button)}
jeremyevans-rodauth-b53f402/templates/verify-account.str000066400000000000000000000006321515725514200235330ustar00rootroot00000000000000
#{rodauth.verify_account_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field') if rodauth.verify_account_set_password?} #{rodauth.render('password-confirm-field') if rodauth.verify_account_set_password? && rodauth.require_password_confirmation?} #{rodauth.button(rodauth.verify_account_button)}
jeremyevans-rodauth-b53f402/templates/verify-login-change-email.str000066400000000000000000000005761515725514200255260ustar00rootroot00000000000000Someone with an account has requested their login be changed to this email address: Old Login: #{rodauth.verify_login_change_old_login} New Login: #{rodauth.verify_login_change_new_login} If you did not request this login change, please ignore this message. If you requested this login change, please go to #{rodauth.verify_login_change_email_link} to verify the login change. jeremyevans-rodauth-b53f402/templates/verify-login-change.str000066400000000000000000000003331515725514200244300ustar00rootroot00000000000000
#{rodauth.verify_login_change_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.button(rodauth.verify_login_change_button)}
jeremyevans-rodauth-b53f402/templates/webauthn-auth.str000066400000000000000000000015531515725514200233540ustar00rootroot00000000000000
#{rodauth.webauthn_auth_additional_form_tags} #{rodauth.csrf_tag(rodauth.webauthn_auth_form_path)}
#{rodauth.button(rodauth.webauthn_auth_button)}
jeremyevans-rodauth-b53f402/templates/webauthn-authenticator-added-email.str000066400000000000000000000003271515725514200274070ustar00rootroot00000000000000Someone (hopefully you) has added a WebAuthn authenticator to the account associated to this email address. There are now #{rodauth.account_webauthn_ids.length} WebAuthn authenticator(s) with access to the account. jeremyevans-rodauth-b53f402/templates/webauthn-authenticator-removed-email.str000066400000000000000000000003331515725514200300040ustar00rootroot00000000000000Someone (hopefully you) has removed a WebAuthn authenticator from the account associated to this email address. There are now #{rodauth.account_webauthn_ids.length} WebAuthn authenticator(s) with access to the account. jeremyevans-rodauth-b53f402/templates/webauthn-autofill.str000066400000000000000000000015131515725514200242260ustar00rootroot00000000000000
#{rodauth.webauthn_auth_additional_form_tags} #{rodauth.csrf_tag(rodauth.webauthn_login_path)} #{rodauth.button(rodauth.webauthn_auth_button, class: "d-none")}
jeremyevans-rodauth-b53f402/templates/webauthn-remove.str000066400000000000000000000020371515725514200237060ustar00rootroot00000000000000
#{rodauth.webauthn_remove_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field') if rodauth.two_factor_modifications_require_password?}
#{(usage = rodauth.account_webauthn_usage; last_id = usage.keys.last; usage;).map do |id, last_use| last_use = last_use.strftime(rodauth.strftime_format) if last_use.is_a?(Time) input = rodauth.input_field_string(rodauth.webauthn_remove_param, "webauthn-remove-#{h id}", :type=>'radio', :class=>"form-check-input", :skip_error_message=>true, :value=>id, :required=>false) label = "" error = rodauth.formatted_field_error(rodauth.webauthn_remove_param) if id == last_id "
#{input}#{label}#{error}
" end.join("\n")}
#{rodauth.button(rodauth.webauthn_remove_button)}
jeremyevans-rodauth-b53f402/templates/webauthn-setup.str000066400000000000000000000015671515725514200235600ustar00rootroot00000000000000
#{rodauth.webauthn_setup_additional_form_tags} #{rodauth.csrf_tag} #{rodauth.render('password-field') if rodauth.two_factor_modifications_require_password?}
#{rodauth.button(rodauth.webauthn_setup_button)}
jeremyevans-rodauth-b53f402/www/000077500000000000000000000000001515725514200166705ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/www/layout.erb000066400000000000000000000026001515725514200206750ustar00rootroot00000000000000 Rodauth: <%= title == 'index' ? "Ruby's Most Advanced Authentication Framework" : title.capitalize %>
<%= content %>
jeremyevans-rodauth-b53f402/www/make_www.rb000077500000000000000000000006611515725514200210440ustar00rootroot00000000000000#!/usr/bin/env ruby require 'erb' require './lib/rodauth/version' Dir.chdir(File.dirname(__FILE__)) erb = ERB.new(File.read('layout.erb')) Dir['pages/*.erb'].each do |page| public_loc = "#{page.gsub(/\Apages\//, 'public/').sub('.erb', '.html')}" content = content = ERB.new(File.read(page)).result(binding) title = title = File.basename(page.sub('.erb', '')) File.open(public_loc, 'wb'){|f| f.write(erb.result(binding))} end jeremyevans-rodauth-b53f402/www/pages/000077500000000000000000000000001515725514200177675ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/www/pages/development.erb000066400000000000000000000027121515725514200230050ustar00rootroot00000000000000

Development

Rodauth, while a mature framework, is still under active development. You can join in on the discussions, ask questions, suggest features, and discuss Rodauth in general on GitHub Discussions or by joining the rodauth Google Group.

Reporting Bugs

To report a bug in Rodauth, use GitHub Issues. If you aren't sure if something is a bug, post a question on GitHub Discussions or the Google Group. Note that GitHub Issues should not be used to ask questions about how to use Rodauth; use GitHub Discussions or the Google Group for that.

Source Code

The master source code repository is jeremyevans/rodauth on GitHub.

Submitting Patches

The easiest way to contribute is to use Git, post the changes to a public repository, and send a pull request, either via GitHub, or the Google Group. Posting patches to the bug tracker or the Google Group works fine as well.

License

Rodauth is distributed under the MIT License. Patches are assumed to be submitted under the same license as Rodauth.

jeremyevans-rodauth-b53f402/www/pages/documentation.erb000066400000000000000000000361411515725514200233370ustar00rootroot00000000000000

Documentation for Rodauth (v<%= Rodauth.version %>)

README (Introduction to Rodauth, start here if new)

RDoc (frames)

Feature Configuration

All features in Rodauth must be explicitly enabled. Configuration that is used by multiple features resides in the Base feature, all other configuration is specific to individual features, and available after the features have been enabled.

  • Base: Shared behavior for other features.
  • Email Base: Shared behavior for features that require sending email.
  • Login Password Requirements Base: Shared behavior for features that set logins or passwords.
  • Two Factor Base: Shared behavior for multifactor authentication features.
  • Account Expiration: Disallows access to accounts if there has been no login or activity after a given amount of time.
  • Active Sessions: Prevents session reuse after logout, and allows for global logout of all sessions for the account.
  • Argon2: Allows use of the argon2 password hash algorithm.
  • Audit Logging: Provides audit logging to a database table for all rodauth actions.
  • Change Login: Allows a user to change their login.
  • Change Password: Allows a user to change their password.
  • Change Password Notify: Emails the user when they use the Change Password feature to change their password.
  • Close Account: Allows a user to close their account.
  • Confirm Password: Allows a user to confirm their password, or multifactor authenticate via password is authenticated via another method.
  • Create Account: Allows a user to create an account.
  • Disallow Common Passwords: Disallows the use of common passwords.
  • Disallow Password Reuse: Disallows setting password to the same string as previous passwords.
  • Email Authentication: Allows login via a link sent via email.
  • HTTP Basic Auth: Allows HTTP basic authentication.
  • Internal Request: Allows interacting with Rodauth by calling methods.
  • JSON: Adds JSON API support for all other features.
  • JWT: Adds JSON Web Token support for all other features.
  • JWT CORS: Supports Cross-Origin Resource Sharing in the JSON API.
  • JWT Refresh: Supports separate access and refresh JWT tokens.
  • Lockout: Locks an account out after a number of invalid authentication attempts, allowing unlocking via email.
  • Login: Allows for logging into the application via a login/email and password.
  • Logout: Allows for logging out of the application, by removing the login information from the session.
  • OTP: Adds support for multifactor authentication via TOTP.
  • OTP Lockout Email: Emails user when TOTP authentication is locked out or unlocked for their account.
  • OTP Modify Email: Emails user when TOTP authentication is setup or disabled for their account.
  • OTP Unlock: Adds support for unlocking TOTP authentication after it is locked out.
  • Password Complexity: Adds more sophisticated complexity checks for passwords.
  • Password Expiration: Requires that accounts change their password after a given amount of time.
  • Password Grace Period: Allows skipping password entry on forms normally requiring it if a user recently entered their password.
  • Password Pepper: Allows appending a secret key to passwords before they are hashed.
  • Path Class Methods: Allows for getting paths/URLs for features using class methods.
  • Recovery Codes: Adds support for mulitfactor authentication via single-use account recovery codes.
  • Remember: Automatically logs a user in based on a token stored in a cookie.
  • Reset Password: Allows users to reset their password if they don't remember it.
  • Reset Password Notify: Emails the user after they have used the Reset Password feature to successfully reset their password.
  • Reset Password Verifies Account: Allows password resets for unverified accounts, and verifies account on successful password reset.
  • Session Expiration: Expires sessions automatically based on inactivity or max lifetime checks.
  • Single Session: Allows only a single active session per account.
  • SMS Codes: Adds support for multifactor authentication via codes received via SMS.
  • Update Password Hash: Updates the password hash whenever the hash cost changes.
  • Verify Account: Requires verifications of newly created accounts before login.
  • Verify Account Grace Period: Allows newly created accounts a grace period before verification is required.
  • Verify Login Change: Requires verification of new logins before changing logins.
  • WebAuthn: Adds support for multifactor authentication via WebAuthn.
  • WebAuthn Autofill: Enables autofill UI for WebAuthn credentials on login.
  • WebAuthn Login: Adds support for passwordless login via WebAuthn.
  • WebAuthn Modify Email: Emails user when a WebAuthn authenticator is added to or removed from their account.
  • WebAuthn Verify Account: Adds support for passwordless WebAuthn setup during account verification.

Guides

External Features

To use these external features, install their dependencies and follow their installation instructions.

External Documentation

Change Log

Release Notes

    <% %w'2 1'.each do |i| %> <% lines = [] Dir["../doc/release_notes/#{i}.*.txt"].map{|f| File.basename(f)}.each do |f| (lines[f.split('.')[1].to_i/10] ||= []) << f end lines.reverse.each do |fs| %>
  • <%= fs.sort_by{|f| f.split('.').map{|x| x.to_i}}.reverse.map do |f| "#{f.sub(/\.txt$/, '').sub(/(..)\.0$/, '\\1')}" end.join(' | ') %>
  • <% end %> <% end %>

License

Presentations

Database Diagram

Here is a diagram of the tables that Rodauth uses and the relationships between the tables as of Rodauth 2.0.0.

Applications Using Rodauth

Here are some open source applications that use Rodauth:

  • SPAM (Simple Personal Accounting Manager)
  • Giftsmas (Gift Tracking)
  • KaeruEra (Exception Tracking)
  • Osso (SAML to OAuth bridge)
  • Ubicloud (Open, Free, and Portable Cloud)
jeremyevans-rodauth-b53f402/www/pages/index.erb000066400000000000000000000021741515725514200215740ustar00rootroot00000000000000

Rodauth: Ruby's Most Advanced Authentication Framework

# cat config.ru
require "roda"

class RodauthApp < Roda
  secret = ENV['SESSION_SECRET']
  plugin :sessions, secret: secret

  # If using Rodauth in a non-Roda application
  # plugin :middleware

  # JSON API
  #plugin :json
  #plugin :json_parser

  plugin :rodauth do
    enable :login, :logout, :verify_account
    enable :webauthn, :otp, :recovery_codes
    hmac_secret secret

    # JSON API
    #enable :jwt
    #jwt_secret secret
    #only_json? false
  end

  route do |r|
    r.rodauth

    rodauth.require_authentication

    # If using Rodauth in a Roda application
    # Your app code here
  end
end

# If using Rodauth in a non-Roda application
# use RodauthApp

# If using Rodauth in a Roda application
run RodauthApp

Rodauth is Ruby's most advanced authentication framework. Find out why you should use it.

jeremyevans-rodauth-b53f402/www/pages/why.erb000066400000000000000000000130461515725514200212740ustar00rootroot00000000000000

Why Rodauth?

Rodauth is Ruby's most advanced authentication framework. There are other authentication frameworks for Ruby, such as Devise, Authlogic, and Sorcery, but all of them are Rails-specific. Rodauth offers many advantages over competing frameworks:

  • Rodauth works with any rack application, not just Rails. It can run as a rack middleware on non-Roda applications. It can also be used as a library in non-web applications.
  • Rodauth uses a more secure password hash storage model that cannot leak password hashes without a privilege escalation attack on the database.
  • Rodauth ships with support for multiple multifactor authentication methods.
  • Rodauth ships with support for multiple passwordless authentication methods.
  • Rodauth ships with a JSON API for all features.
  • Rodauth supports overriding of almost all behavior on a per-request basis using a simple DSL.

Goals

  • Security
  • Simplicity
  • Flexibility

Security

Rodauth ships in a maximum security by default configuration. The default and recommended way to use Rodauth is with multiple database accounts and using database functions for authentication, in order to protect access to password hashes from attackers. Even if you are storing nothing else important in your application, if you are storing password hashes, it is critical that you protect access to them as much as possible, so that an attacker will not be able to use the password hashes stored in your database to attack other sites. However, if you are not able to use this more secure mode, Rodauth also supports more typical methods of password storage.

Rodauth ships with support for multiple multifactor authentication methods including WebAuthn and TOTP, protecting your site from password hash attacks on other sites.

Rodauth ships with support for multiple passwordless authentication methods, allowing users to login without having passwords at all.

For tokens stored in the database (e.g. for resetting passwords), Rodauth can use an HMAC such that an SQL injection vulnerability in the application to leak the tokens will result in unusable tokens unless the application's HMAC secret is also compromised.

Simplicity

Rodauth uses a simple configuration DSL that allows easily constructing a custom authentication object designed for your application.

Flexibility

Rodauth allows for overriding any part of the framework on a per-request basis using any information related to the request, by passing a block to any configuration method.

Full Featured

Rodauth ships with support for a large amount of authentication features, such as:

  • Login
  • Logout
  • Change Password
  • Change Login
  • Reset Password
  • Create Account
  • Close Account
  • Verify Account
  • Remember (Autologin via token)
  • Lockout (Bruteforce protection)
  • Audit Logging
  • Email Authentication (Passwordless login via email link)
  • WebAuthn (Multifactor authentication via WebAuthn)
  • WebAuthn Login (Passwordless login via WebAuthn)
  • WebAuthn Verify Account (Passwordless WebAuthn Setup)
  • WebAuthn Autofill (Autofill WebAuthn credentials on login)
  • WebAuthn Modify Email (Email when WebAuthn authenticator added or removed)
  • OTP (Multifactor authentication via TOTP)
  • OTP Modify Email (Email when TOTP authentication setup or disabled)
  • OTP Unlock (Unlock TOTP authentication after lockout)
  • OTP Lockout Email (Email when TOTP authentication locked out or unlocked)
  • Recovery Codes (Multifactor authentication via backup codes)
  • SMS Codes (Multifactor authentication via SMS)
  • Verify Login Change (Reverify accounts after login changes)
  • Verify Account Grace Period (Don't require verification before login)
  • Password Grace Period (Don't require password entry if recently entered)
  • Password Complexity (More sophisticated checks)
  • Password Pepper (Appends secret to password before hashing)
  • Change Password Notify (Notify user about password changes)
  • Reset Password Notify (Notify user about completed password resets)
  • Confirm Password (Ask user to enter password if logged in via a token)
  • Update Password Hash (If changing password hash cost)
  • Argon2 (Alternative Password hash algorithm)
  • Disallow Common Passwords
  • Disallow Password Reuse
  • Password Expiration
  • Account Expiration
  • Session Expiration
  • Active Sessions (Prevent session reuse after logout, allow logout of all sessions)
  • Single Session (Only one active session per account)
  • HTTP Basic Auth
  • JSON (JSON API support for all other features)
  • JWT (JSON Web Token support for all other features)
  • JWT CORS (Cross-Origin Resource Sharing)
  • JWT Refresh (Access & refresh tokens)
  • Internal Request (Interact with Rodauth via methods)
  • Path Class Methods (Get paths/URLs for features via class methods)

You can learn more about these features by reviewing Rodauth's documentation.

jeremyevans-rodauth-b53f402/www/public/000077500000000000000000000000001515725514200201465ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/www/public/css/000077500000000000000000000000001515725514200207365ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/www/public/css/rodauth.css000066400000000000000000000013171515725514200231200ustar00rootroot00000000000000nav.navbar.navbar-default a.navbar-brand, nav.navbar.navbar-default ul.nav > li > a { color: white; } h1.byline { margin-bottom: 30px; font-weight: bold; } a.navbar-brand { margin-right: 30px; font-size: 32px; } a.navbar-brand > img { display: inline-block; margin-top: -10px; } body #content a { color: #5E0000; font-weight: bold; text-decoration: underline; } nav.navbar { background: #2290FF; } body, p, h1, h2, h3, a { font-family: 'Open Sans', sans-serif; } body { color: #5E0000; background: #9DC6FF; } .path, .code { background: white; padding: 3px 5px; } .path, .code, pre code { font-family: 'PT Mono', monospace; font-size: 14px; } body, p, li { font-size: 16px; } jeremyevans-rodauth-b53f402/www/public/images/000077500000000000000000000000001515725514200214135ustar00rootroot00000000000000jeremyevans-rodauth-b53f402/www/public/images/rodauth-db-diagram-thumb.png000066400000000000000000000316731515725514200267030ustar00rootroot00000000000000PNG  IHDRf٠gAMA a cHRMz&u0`:pQ<bKGDtIME7".2IDATxKKv;|U\{ѯ8cMYhZI@;} k+3-$M#jF2MB=tw-<"3* (< O.y! "=]_{on`@8pSum"}g??:dz7=췭wB I,L&uxa8RU%IPUu;^>g6$Iyk-!x$IRtNUUȿ7Yh !).}{G`]@u:@|рt.oJiB)pk)si8|t"{}6iixn߷nA67?}?ڇ[|vhm}qf=ZiҀg0 !x5GG8h Bk%L\c\R|Q֎X6Ebttģ1)HSk#%@|C]Wx/v#;{SgY^OC~)xq~PFk ʀ2H k}{`)D}fKkZqx1ՊcRщVēe I)о$L!;3ƃx^\ E 28Ez'|C;)_>{]Om#tu]u@o;D~%~;[7\Dxzߦ1_K `mP^ !8#hcUQz6 Ue4Mt6;qI3c- Ji\pFDEvطv/[`st1P֊?|E]U(UaBJE`( 431ptģq\]Q-4з$^])}{Cfz_~3qPA!ltq5p4J`nQ)GGb$}jBb I12 /^WSD,y&>шff:MD`Lؔop" + u:N|=v'h J,A!h9W@? lra)9q GwRUo\O)˒ߢi譳$IByN}&. i6b0;% ,16 >ʲ$RRmᨡAtȺZ{u8=-ɪJ/Kuxf?8S~8˿k (i nH+r8)5-?/Ԧ&7,7kGutD% 5<:9&W>qKa'rQu]蓮 kV(SHaOq9G] +?mx?63OY(%<5/^7~P#IC5  c5/n8R c4Mwo;;/"q'hڄ|b1, D !<8wz"F\jV5ibgh " M\TYM]7fsN:k~ol6fcJg!QI*ԈIz"٘P,mw*ϟxRc gWBmQa=Aays۱OLezrtǎI9?Ylw,r0/AFSHBp@֎4G|Cj~xrC/Q>9nwz !Ԑ۷UJ?|yMOPq 7,P7wlVc9>~OY>m;m-1|rO[x:?5vǒ3z,KR- o.&>E (!oJ)!$]\ +f-\80k={bINZk_i7.=XCeM$D'SIPWJk&(uk`C~#ݠ'kAFia\1𾦮=Y4 EQ܀ 8 3A@b;3"ʲ$ cL "AC- y4>!`X A%0ׯY=%ii~@VWk%M]oT=uw;HY+GVEĪXuLv8. :lyopÃ8y j2PT$ FXX. HeV(0k_o_!V PV% ˺@+pw /^nT]BAC\97M}{3k?&W89ֆ|£ zRUx TuM&x!x~4M1Zq=&z:Ź#BbDoPBILz1Zy"chmDCۀni;+(߶^>nJ("/I<,n! 7wC GEY1z(6HC8y4E݄P0~6 㪼%jK{2x,g1Ns9~v=@U \Qy5SHF%Z1/1"4CiIBj5ZV, GoX hKT;E4>q߻4_?KW ;: S\9x=-i\-z$[3ݗw_zFa*aSMTD@*kI.B>Bb`OZǥ?q@:}GgPG%4~ xR< jH 6&tfٰ*%1Cqw{|\{)2ATp6&;Ͼm xN7q(h:ea`:p8c\TŃg˗_rt<7W3,Q 5 hٺ: [o5YXyh5]<"UUljh bD 9-~K PJ)^A@`S mj ԛe teϺ*&n],6Y1 QBY)>10M6q[>_ɿi*R\LaW>9&/Jm%Ue|<@Efo%M>勗_\=xrK]坌14O>BI M ~!~5xhbhF7APkY %kJw  ԔǽێppPN~4nN4·mb 9HIiM']Pι-lm%g_;҄2^=G\'|']'c-ZtƐeW/E^ ,jnwop_i\?FLQ4S'zxdX+ N1^ i>͗_~ztCܽHtZjQ[PPD[m |T +-\CHmܔbFܺ+4`&0Վ=@`ƕhqqn|̙@C-ESy p/PքbMs/1XZQLe' Cw?i؏% Ub.+Oh?5:Gm .h)ʲZK4 [Cg _[k0 OeON"{z@0Ql6eH )F3ɒWL1!Isuf̷XLڼM]a"o\ \"x.x+i2(j Up5X."iJk]#-ܽAތ" {ЇlFa6^)M~Ŧ@)Zh9$^WO"o_1[æ\D>D7ɩ'(=jJY/q{ %85_?dIƜW*fA=%!B6&,/Պh' 9[@NS#zg'Ze%ʛiX:P!z|P /s8g߼lJ@vuA1kE}D`]F`B{*UkPV!2>$E[M)ONϖ<:9&MR9$h#m¤ s@ƅ%?} g$BhEFQZ)PY ƾ]nvqbuN9W iWjex"Ei (yr[2sLbNWH}L}&IA;8:>J99}5|6phL]W\M_@Mb}+\y~ATJCr4j|6EA\W ~źpMjh<UdtJU]4e0c6)Ib1 Z(jXpMf*这0r1G8穛Wn2D}X >j<\t/!fDmf ńGG#@Hes=qt4F Tu%u}wzG5%@Z^S7 eݠX`8@k3RqE)Z!&nð'u+DL k,y;ZFnsa]79G깲#$!g|g }6-44ZL y2Vωڋȫ6dYUc7 \UbPqǗ$l !7xpPJQB9e D[C4eH u ^ 8ED)tBo!du]S%U]Q7u;SIj,+'7U>{5FV| ^׳x]J{kO!xDG^1jjzjq"ju+O" Ռ`nCU"C2R۠o] TSF Z`cSKrQN3Ԏ5nDag 忢??>v33ApI"Z"XOZ`]¤{0BS#ws $u#y彟ݩv[=Z̆r,5BD~OE_V!f ߠw壝k-jV5̮.ޱ^o=<햶N!|Q* >8-8>=:ÒhHU.y}qt'  a`VyVIUmuZk~V˳ zd5eMBpxi4KiG]UEq:ThIX.W7J!CdZEVu]ӍsP+$lH&ߐ&=&+fO8:B }eL•eFHW(#AEJk҂X-JmCuc ʪ$K(٬dxuL#ѢcD7 G\^<0H;#4b+1cb' `B,r3H_%>4,hk@ U > w7Z 5DJDnu%Tgu,sc,!x~t{ ,3]5ZgFBZk&֩AGDr qqu9Akuy|$͠Xb[͑FȮ3-ݏich:YC+l6ˆI%j1B'w$ɽvRaOvu LX^@Ug܏^] 2J_BAy^PM$ N%^7 ao޻^XbI 1,4&@ä]rry`}.!eM/^ 47/ikrݢiyg`:|MķuL$.Mjk";rVwۻǿoMyC6%ةQ-{m˼oo'+=ک܊ߡKq} 9b {[\焼صA |`Z~j2j~p+&8z4whmQEY$,ӻn|X5[QZ1L9>>&2 k\_c{)FES7֦|)ח<ɄLQ5c]9^o<"d?Z]]91<~D]Vi5fMRMS(I(<쓄,(]8*#Po_,^jrRTmXedLB, 뻩Ij궏<+ $p\Kr#r|:^s Ʒ;خVm!_vEжMIvvK[^oOQ\Ϲ8$CC ;d&CD e Iὧ"l$B#5qA4EzΓ'ι^֐Zw߼Zlʋg7K04zhe!`a'~pB'DVdpZ_pF?c4UUgXՄW]#5PerR"bא/xz8|6A#0}r0)a1Ck5wA;|cpc8jbc#qe,>!nq>䋭$tTQIp:`KUEq9wKDWyQ*D4IB|u9ںn\8*"<]4 а,4&Ҟ2@YGsȮ-a-e)I 咬j,9P' ,8 +zwm)˚i_(.۳O\]M!gP/Xve-NG^/f=DhV|ɔ^p^N~A63F{>j"B$^kDxhnN۾ЀhBI1ץbP6}pu0JE)Yb%&@@TESyV]pmgt0ԭD)힊7$8ŶChl<FOI%|t m$jR-"WNwۛiCXE~8bHtTXa%-P{]X.-9knыy $Af:z:t8gzO=Lt{d{7^ޠk C䟵<~(x?)Ʃr 2p>!"L`6ኂ}*men.M=n䌘t=9ժ&K2i?Ccж[SkVᰏR*,'ti䠼TQl;$Ix|DWdQE,I{U]F!YDm-ZZr1gZau =$֛uDZN.l1=Np# P/+uұP;W/Ψxއv6jj U=$eIQTq;g+諳 ۏu78H/;.jEUzkT,8%՞a>&:aZi^g<_WggetA?>I}50O|U@bPupx5R1=Q%Ρf^}9!wkeߴhO:z)zHFԢ3 !"1.A@?,^\ijZ$1 ;",/PՆQUt\_OB x_"4yt!_"uLwpfEVjj((ٝLǷ",_m}h$wQGUnev=irm . TsBfc(^p^ Oulz{̄u $Mt=MOV_^⠝yHya i_g^beLry.tqzDg_!PV d/`C]W爵 zuU3-Tyη77$V 8 WB@ A᩻9wCNONXV#u&{ס/O/DDC(H2jtJ/B{L4Iz Fwb(fxMIbI8V\_Mh&\T} !\FKծQJ"BUL7 (i9{I?i}lSϥs>XP! iy18 )IY6&Nm|I !Qz<0e1dݛk\(MF(β/V9~819(^csA~ig㌼l6DQiz:bW#<ݎ_W (DC Z2*CRmGwf`e<>* 28l69G1)m E b > )bxړF)R൉Ղ!>57xLD rM,/ G#kRj-[EYUw&ʙfl:m9ێP\oVX*ua矴 ةǬb)1]i#y2syqj%RWKqQoOxx$x˶zrўpC 4?˽ܷfs,`NO/lu7d9 #6UIkGdhk9=}k,rnQt؇a뉠|*0"Kv C5 EY}88cI9!\̓{O;?O>=Y:Cn;CIO{x])+"s >{~`lAKZ%iwJk I4pMR$>:='E`e^KVn~!ޣRK/є1MbQ \Y }%l8ЗzK hX7ȶ y}Ro:d C :wQ|$\Moh(`Tw0] xL?M9$M-~~O#6 p u.OeS~gdSbu*IwdQ9_N;fcޏ. iIzki"2zY`S rrV=L'5*XN8yv !DnxhO X֧wu]m9!DIW#۠FT=gOS%l?)zF!g^6u? H㴊w}};Hdz>f<>9O &-hB7[(k.9 dJP ^uIiuU|?">y zbR:㺒H PR000H2"/ɜ4qkc7kGL$}Bl\ﭗ?`@ z7ub Q3=iZ lv/*Eʳ,G+ڀci$Pcw$cA~imc7G4k1ZS%Jj>)!S%{J)9O?F! >w k%Q&2hzJk|<[^lgV*95G|˛A$bL͂9>k t7s^5EQzSLBj-EU^<{v&/PIlB^uEd hjWK&ك9ZK-'ilk&%HImk-u]Qgs'A6e{[{fŠ[o&I9wE]Bq@BksM[/w ֤$YJِOuMSĦdUz'1SgAsHP<y}~N_w"q1=;(kr S9W$Icyݝ :IR^|礱XwƶQ.;ڗz:'GRkȋ2o8M)@_C^ӧ:b96S&v^$-Z*"c^_^Q'QX' GsF(*6e@zcdlf HtU,X8*R?JE\,n;e} DD$8x2w;d*f'^/~6Hi<M{&sb9M4hZΨ]g'X,Qy1OĤt&:H/!j4cX4kE@Ycn{$V{U+O3ǵuMYTCTp\\^BQ(@ *z^7McBrX8~l>_0o}KIRqYwL KNFQ#IR;lL]WkjK;lC;"mkZ)..wacl14VcUFAN<3{A{R|3A>)K^_"a=&ɻ|V3C zww&p{(*(CrSJ}ߍ߱O}`?|5=W%tEXtdate:create2020-05-15T16:50:45+00:00U&N%tEXtdate:modify2020-05-15T16:50:17+00:00XIENDB`jeremyevans-rodauth-b53f402/www/public/images/rodauth-db-diagram.png000066400000000000000000002654731515725514200255750ustar00rootroot00000000000000PNG  IHDRPʷe$IDATx콋UՙHK/ "\&kE b5^HK0ӑ(j |3R!*)/ye6)}ͽwq f.?;#c@p#! L0|GjX+܉r5꫹EK|'M>σp2T͗|1`^mnnmF|p9rT0r=l V۫7޻_~(R}773?kޏ!}1u$Pr}=o4U%M5r6KWDT+_?:8yglHKQ)aھDVIQΑQeQ]-j7w2Τ~+Q|`(k*P>sݻ|[SyWH=^;pO=tzB2PHO~5VkI 'h]CrK[k55}ii5}-Dd|}3ᖋ(s&O9>gYIp%r{ƒ> r_sڳW]wI?CD6C^ov(Noa\xyǃ>wUse|888_;CS{mϦ~}v|zzԾ=gCxtei5EDIU䧌I;"U䡇6ȱrN T=կ~'X_:Zf7㵦ݷF5*t|>_A*yToNw1'wNE5%VfR*|dݹY2Xۆ}W9)F8gHE+UIW,'Q/ϳ\n~=cv<ً&eU?,"[%3)JS̙sj.P5k!B.ost+W#6lv?Y=4@jG"GEt&EJ*L$*ǸD6M)P~]E[B/W k/^F/ǯZ&8^?+MdLm~;wg3K߾#iZcQElDHZ"AO"4ZNUH;"L\n~e uA$^H*ux`Sx_)S)iMʍSiWXgk҈Oy>1%ͽl,gg,HD'mJeڵxWyE'nuR$J7)X^˖@_ǚ;CGDu|9y>w̫~W9w}wqJ>RV>cy>)є" ׭Ffv}jFY555]ekUJZڟRuf/(+<6- Rxq5@Z˿$ecǿ /" |绱^=gSn&"uEL2=Ym Th-(Þ2>="1fהkH'ssm2~E0׷E,sTN~φ6N}&p. yZ}_WKẒ~}O3WvoN*W_q(U"UŶMOVoo @:*~6"sπKgy7hUyV+33 QYh'iznKBҔ}ʮSLL"hU@sݮv?*g$դ4{f.{hZp )i'BޱeU~g#46x&+Mv|.]P*YWJ<ւD=ط blZ#Ւ{y -hj#Kv:Fj*'487x?V8C6mV0ary$xydUB|,g{v?}ks_u|z"5Rю#P5 6-U_PTW0ڒ7wFٲ|6yRikϕD9vtϽV[pky=D-5TкrK>GjR/=Kz~1(Ф{rbӎu-mc{j\,HN=n[RTiV9riקkꓷ*j[FjʱDUiD]xƮ{(_n `kk[sI RONuSij < :"N$XREr*'PZ[R ݆CFi 6W®u9-J&q3atQ^n>]ssŊUKʵ+&uq.{ӄDHLBۖ_>},'PS 9>i,BJՈu,g|F2繯IvįCeIsl/0eոoش\D$KvǩD9y?P}ƪqSϤ4w;mWT-1k%P5T$D摧_k;W@>lJU=i/Y^Tm#uvEv6|ǫPr B'>TIִ ס2?"G4PH ,59}s- L\TknhvvD%깲S9uL3ᎧҤeg9$=cI;=@s_ckI-M[>~?'un~" JX|]N>Cy3dϦP=2?\3>cy=GtR)P$.m(yh/"eO{'9FScd."WjT>l:٧UhU:/^OǴM#Po?v}>{^;֞kGr.*t*|m m_X-C%PEdr}vO@m"v·|Z/?{09OSeg~dUy[f-^xC!P-u %TW$RC} !%Fŀ7mpDui26rNR*E%U<>y'붟3+PӮ%}W5Q}n- gHYcoGIUpU("τ\ôi;CISϦ^Hfy?y}'br9^Ƥs!ɵX{jQ} OًHʹ,㶣=^څvZOr.֣T mqZ ׶==VǛ6i̩}8캟OƩu>|+_Im9tS:'ҮOf}^x[Wk,癟g,鳰u0wkr_ =F~A~q1*hwk۾y?:VG=;-X{_@@M[tD%+.$ LE $9T]ѨSQJTmGHk[dֺiPnUŇ ;.?6e_7WHҮ/Xyy$P,=C*dgGҳg~1=imA ʴrR~:rmWa'ȜoIҎ^A!) :woA)gSSU<{I, 0$էx]&@W+1hG*w@T@-CjN "~~HG OShWՊ*4@8B2Z Ӯ *"5~0 PT* @@ P@@ PT* @hN:s|qT* @@ PT*@Ŝ`ק=l83@5Ǽsӿ?y=X*̧yGvֿ@8j@mkl*@=IDS:/ʴN799'<'<ڞ݊b1{>k@NE<\1)xm۝ҵLwemiϩ눌'u'@1iިŔ}XğP4IsՎ@-lb\G7"Z2[@}I|ȑƇJ͙@4pZ|eK$OD#b4MDV]J{Nt%m5.+3 kT1Y}\kd"OMqꦉ#P=byXj0 @rTע+j^O_)|R6i$cV:I!̍W'խ3EP E$wj[Fa&K[)Pa5>)tsJآ'rU$:7!6)(,R[ʕ뷯b4Noצo-@@DF"'(P*(Eʌi !Pm7 `Y`-@Px;M[ @i^\(?So?i0UM7M0cjƍt3=)| P+Ь3 T* Q r3cFgL1kn/Xi._kV,_,^aږF>6|W -ys/?kϗuY>c f޼|/ P/]S6EKWy󗛹4-#"uU|7 0|gӄ<22~*@K1fP.\D*ZS]cވPeաD3g1@@B+ѫ#| -ǯ]@p9kA P;m,ThU>9|u:or+ @* ƿzC P,lThYڍ@Y3 3~dzO5j} + uP.Zw P@m~L@o~5{k&3oL0[UMp Ks|}3!a&<io;B@E PZUZ ԡr>@|D5 T*@EUDU)MFhaI%mF*sf;ģ%Q{獢.Pk~J(-;o.nGΑ;M wS7}M Լ T* P$WptjLm/HXbLZyҿ%*@dg @)3"PsTN1G߈ VXs$fstM=F\ā٣Er\-sLQZWu׃xl3g @@5{}K70R$ߜ%P]a:~OJꙝS>iG;7o}*'?9fV;Y@gϞ5b.@ƄLj@6|jqFς;@0{7E /T@+ka%?Z?i]߷-46m'!3Ue1ZUP4IfIwӾg er H;+">K3Az$jR"RX5O Է:iݐ?.ZIyY<{yF=|YLBR_e<2ʬU黻Pϵ"K⋈-TUqr,g#P]) 'P6De[k .!9@ P̟}jo$[#̟?wYxyyλ~MOcO҆D Ҧ\=ǟ4K$OycwguX@vDj Rk:L_DjW,ǏT* Puۮ%OKLטy3UܿE*44U6_z`(:N9NeB'P])*?KƝw @*p T,݁@@5ա+rS_fqAkԧ R~Ut;Mi)>*/;Tռc* P[JTTs T@~^Z@Ƿ.1G}u˾</s`j * PThuNO%2@\0s ThTeO?b^b^bP-UˤcR P @E P ?m^7?>t9 9fz;# +l;6k~/8B}9e`s辩ۦzTKǰmC}@E=4!ۦ;`9kA*Q-F #P}R*=j @B˳/7_ O P2@YCmcѬF P;Vw P5&m躩U i( MT*Tdm(ԹsT*4@|.@s T,s|iѶ\ZJG"P@hlNZ/@ PQG=.MiZN]wjdNUkr#L&GB"P@h8/lNZѾ/@ P*S7Q@E ;ă'v5P@ՙ;YwRM| TH L}@EB ff|i_m|%_Tzy'ɇϛi!g¾ ϛZϐ/O@; P @* !h6:L_o&TJws@:,Y<&Ϙ2?ky;1fi>@B T: ~Y] *U#Ȼ Ew_!SS4*0hP[VM}L|}h~RhұA$^ aY) n4>/wU;vw}-W(E+NThu:eLs3|tuuTm]]/;ǧs]y}ٳf;B=_%-/v)LJE8L4#EUӶ *5-2R>MJ@8R.J "P2KFdgAh./^, քsmߪ{'8oE"P%ԩ3ͽo{nwL&!ѩ=>}vP"vwny]gq8)Jϵ+PE,kEB3q' P. ~Q輇 *8JKċoSԨt@uiS'PK:{ TNi(T*ZZzT~f=婝ֿq̤I@QU*PEKP}U\M KPex5bM纍/t=@Eر"~jסEJ)E&GH3叔0?%" -%P.\fxTyfɒΒ.~GnѫZ @"ZhP_*׮]G(Z{,_tSY_ymimW7EBt TKn#TOȰŠO%rrїYV b2,N1raZ C㒵 5ZUu"RBUThY*v=W@`E}OgR*@*'U蔗QrM$ilhG#M5V'PmӽNiKBk]XZkUmԒEPb@?}6c.B Z橤ki{|G PE9Yb1o˪ht(lqU~2F:\&#`?!kI'R4Jԉ@-|N@-Jٺ]D+PKd9Ej"Pc=a^|Ŋ,,o=UQ"?5"S+RU8JHtS5"5kri0ק/ww( /vi}o h{Io^{/Џ?X^Ӿb%@p(e#FV~@U1oGm/ YnRDUBӗoKSF;T#I^(Yڶ^GRY*"n_h[n.j`%@Ӳ`aF)J*U{<}WMSjRtkfMK1RŨ++yڗo䩽:@MMWcET|c5^WUDhvUTJJYM>i+>E S+iI_9*4;3f@@**P׮`v^uϞw]@yPdHWj}#mjѤHr+k=^Sn j÷ xjͮ@@"P  9-B4}'efϞC% \l P T* PB7,Z<ӹOmo02P8@@"Pp̷͓ٷo_Yq*N=̷͚5SCLMZ @E"P@@Hԅ -[|{tu,eK^kveolJs-C>m}* Pa(~GмU%i+6v=g{nwq*rs5kn* Pa(?yb{y(@]j-*4@@ PٳW^6o}*|~R@* %LEc3@sO\̉zgLp@mm{<&OT* P@hjNOysc.:sO{@yO#G T @*!5`8*@ T"PT@"P )};|RLWԗi^~:W_=O$h54NDH^GVyKF'yɘUV3Rj"#ˤ' V鷘oT}7I'Wie@5{Y0-6o:s@*ZHzԶt߳sR]Ȋ,+P=QN9tZʾDsR\Y +e~U+, lrw[ln@E"P T@ P`(,@E@j}b3x[m͹Jm2TdfTJ?[ՕHS9tN\S8$g7Ws@@ TeR|i$5?VS BSTOLRF= P Giic~ϡCmFv4j6QtuVIG@K 1S&72W?\qxӌZkx}3ks̘i}@*a#PHZ_# GEC鐰ckAf #,@@ xevkϫo>-':%]J@7"/ij(N ErrDڂSDblj uǜԯݖ-@}딗\1F⬙v/hfΛߘ`>Ku=AE6@ѡ%P,m{w$j% T91~Z!ru+@n5ccfi{{,5t*5-nj^ܿTh:ztsW,P8ʌ^9"*PR5RD.dzJoSqiFU) E9WcG+m =T6K إlYk/,@j fj"@}}3mM Xסw T9)%/W D3H{S?>o&?XX@ETJh9H<UfIWTZX-/U΍ TKmGuYk|hREiXr355@ |5V8~W喧a28qbÈB*':  Բ@(z~i ϖԳfb{z?vWW"P5n~ލ$FzQUWVAZǖUO5+L>C T;*[KccrThPH['6 J]zz]yyuQ?f$ PaRt]):/}ѨJg'wΉGEK ˂]9&usXJ"s-nGZ"%3}Cz蚁kJ(Zx 9L  y*uj "C}QC)Pv[>e@uU5@*Tu$3jls+ Jb}"Xxm63fdd Py"PmGGƄTE%;>+@'["PKS#0Er:U Eq; V Ф@{_x'-znfr"P5 UYR JE)FZQvTT@k?UYoIMERSs M'P&ʪo^v|^A^5WZh_?ˌm{5^x^tZYq0mXJxӥ:>iG(hg*A*-kD"R*ot[a).TeTV:~@'H٦Zi䮽"RvѥOnZ[`JIR@Th)D|#3(Po@ "HR@?ND SJ ?* P @j&²"($Ji Ԅ2jhM%PPd?xj|WTg@EThE9Yb1 m&kqi[VT,rUVP E96f&-T2B TTRt,'Qҳ_M@E"P@E PnMTT*T* PscGF տEF{@"PT@"P@* @E T@"P@*@E@gz}bz6v}hF?f1T@ P T*wlNsʵk yjfls䮭%i9T* P@*ZK9f>+C+9Z T@ P Tj T Ƃl9)͑`[Cx.vT?]Vv_[͑>kl'yݗJyxOؿ1k_qn1|lFVJ5׌;s׌mvs׌:^hgD3j{qR[D"P @* P'PL},ϋ0 l}>ٞiYTXQHR[&][<(uy0A+{G K&Zz%7S#Pa@ʘ>w^ P @* P P}@-PYrҎ4Es\FfhN# F&suS< ^4~\13@B=ѡj4&=?6.gl@&c5ڞ4Wv0sC_/=l.ͥ?5Wv\`%>@BC 0 l1*u7b3EҊB|ILzĨWV^5*RSjDZD:䪁SRK M,Poy>g.o< ?ejYlr;аU$ yMw|6Kv PUH*@AjhXtEgҵfÖnj~=/CSN[j:~)ԴyIO^*5>@ \Y_7Wl\#N=d~@Rf)?7x׬_@"RNAe*.0XS6hEαApniWVF]DMwe[@/ؕkF"RKH lFZ?Vj}4Rso)'Mz K[~e7liҿw1%] >y=Ww1knC_>,'{mKS%v̦M[2E;{lxF9veO~0u}G䩲+T* PNZaZSsɦ,O/wgn_z`(E헟m*"R-Oq({iG;}m"E4}$uǣS=~9NږWV2ڮ e^["#P -P|jW "1bAU"5ZAt -FT@Fnsٽ_j]UrȓhHLIWӇ@gqQ#6}2Q}+vi6_.9.Puoyps^o>SDW߯5|n3@4WvsU"WyE}KDD_ ;@UA)疓Ү7f73@Mw,znZTh%s]* PjASW>M@E˖ rsc.n=x@w.HůT~i2*[񴥤FVjQY Cz~R{VMIѣy|cڤ̡@q)Y0Jj8=|aT*T)¼y+ P!P/yb4BDLSiIi*Zvڸ}@*7{JSiG6zfT&!FYN&T#Lݗ\sTRdk,BǚQ]DBK +BPοoM1%DIgrDijʼF67ގV"R6IcIڵ۫tF"P5"P' AVj%m:||C6:RD+S([SԒi@@qۑsW %TVn+ PT\zAZ@}_U+ZQ߮ztk&P;]E0zҟ+4e_R=Ԥ{J'j57*@wNS̮ps##JQ9>=ko ">Z(6OІ.94^0GVTr:ۏ3d^Xgs^;ގ@C3^328Xq,~6\E"PkW[[70||ϝˢQL4ż9'GGb>hv'wq'`W$p޹HOR/>1 Ќ+3+6U{ϛoV@H44ѡm9FEk 8x]I BNic֚JnW DSih\T{etOdfD&?/c+RuDȒL %*HsBQZ&m?8W6OK_"t?4;c 7_4,@-Ǽ}l3w\ֶ,x/Y=@ 1 sT(OdaMOΤER90<Y$iA:% ŗN\/"e*Zwq.±i.JxBKoi]+<4W/Ӂ_(rrE'ћpRt&+icqTΡۮ/׋["W(# sH]ٮ7:%P]}FiZ@ އRV1[@E@jy7TK2f%{v3W~|uR@FeAhZq,[4GDj>.d5ئ)vs Ԡ|9%4:k%PҾ,1yjHT$) +P&@zUe]YyzN=3I?@BS P;ix-/U:LREWHJ_m'D& ԄrA;LOkMNNdh{!a:xjLITH[TL:#-P |ubsݼ+;N[sCA/Ks V" .P4}k%;1+='-?"Rń"UOą"c QDNgѪ\%EFmDT^3ڍ.-&ur(Ⱦ9i5y+@"P3j Ա7M6_܌j{Ҍl?,.u}%8U2anin7ThT ~@"Pи *rFt68-y T*T@ @z`W־cFmKxjӾr۬,:mV`.4s.C"P@*@*4;n+>t!7dcEfΜT* PT*ЌZ**PW[̘L9,f 0gRtPY{;o T*@*4SS ddڌ8DmHT*C~s?(f][ *ssӞ83@ܟgEs ^ P[+Po@&NJTIG"P PeU+to';t5@{C^3t1|lFmVT*'N& SS?Tܜ6@35l"R[J K!PPL0 Te ԡrE Ԫ &a5~* V@ZuN*C$P9f>8I]s͑GGU(PhD7Wa|vGNמvu*}~iE%13No﵎uq;畽q>4#_5#cRnU*_x dTuP}v-|?/UWFӎk@ TWjF߻jAz&DV PUF@"P5^$(wlzr}QafI",%ʰbF6נ~$-*#+טYL{6b@M@4{,2(Y3&?>#'x* ,P#Q u>]XQz|M/!P]HJOU"N-Gdin$Id]@%Jsi~}<EYj%NJuL-P#%|\=iiY^.z))>Iܧآ痛i ϢD7*P+/1nѥ@"P[X>PE@M[N؝A@jz?'*S*5ŘBii/t%-sR/]_*]*ZU 9+Kc/:힓:kӨv*kFѥڤ"RnW9'M&/|TjmV,R=;3.LX Dd(+b/@75jY"RɒM%H(CK/0Q3o s]dE*N/,&>7}2~{_y왪tnڹ&i)*5@dE"Q U(sfhd8< \1F(9DusXJ"s-nGZ"%3}Cz蚁kJ(6Wm*@* PT갂@g Tj=76&c]T*@@] G}b`,D;ϒU:s1s䤞o{}d*T* PRZ5JT*T*8k>*Ԋ bbr ͋@@"P@\9hT@ PT*)@ ? PT@"P T*T@@*@( -;MVIET* P@j .wβ h1p@E T rɮ`onm3t(caGymvYDz⣏>;q{>;EשŮEW[C{e@vLp]<"Pa ;#AO(vF)Z@-"(U=*(ԑWyg]smti*@Grx$6rM󬊇;~Mʦ@"P}Ο,>lUg?e b/EwyWo/pcj':Bة^ϗם@ PJ)A:m3Fk^5陣@AIq5 TC T55 QP"\^4 nZ }tO|@E"P3sy@mS.>h%jD6GZ;5~G"P4 yt䬃hd* PM8U*:A&T2]).//X_[I3m@5dsZ@"G*EjM5)M^MŨW4@"*0"PtSjYEcjZïH9/@* Іfĥ8Fx%I6ΏH*PT6g''xeS<ڔf61Rԑxj5P}fFcfɒ4@*I%*RJ@5l7yjY4C72itZkTTneZH9er_+^ T[4Y?/K ku@ {%*@@@#Ѣ7źY260-0Qв$JS5z"0)io6Q.M\Grf6)0n^kf +9_kyOD&6DeB\?Մ&SaXj;8v@E PrZC8TլF-zTjqm%z@* ޺@u"r@mQ?>@jc\ *(T6^O&E}FRӏ#R!< Pi"@*5E.}N,kK{O]F<&@* @E@ \ Th@7ѩ$V6YZ .n]s?ټy$xYQWP2mbtkfw0j{ T/}!Mc}PT@@ ܽ5ҽu<~2{ MWQAaENE&*PIMEB T3b%"GP) 3OtSC 2ҋTjP/HONOߗ > Ԕ2Tx;3@@ 3*@NLM ?8M..UƃꚖԈT%#ըoD RIi*d3 Prbo߈s=%M Prb&/m(P&ϡ=HIԯywqGg"P]>^] q-&J[D$oxlY/4Br郿B]}A}Au|YB ׾_☤ M W)\~AHp7 n96׼Oßȍq*@@B5 *@|{xѹwNLi{0Jjc(@y&@]ѩϩ.]UBzFl~DL:@*4j+sxvx!>k{'Uʨ0VR4ZIq YrC)b/1<2D-ΉTEGV.Po}F|3ִdSǽcnqJmde]r%3]C"Pi"@@B+g?4goâwXʴ- T@ PD G6ҢTPl|zh%$S'>)u m)?=\W3[9R:-籲Y[vu8c9 Z/t,/-jHDrGʽ@yR.&ӑP aTb6]ݿ*t@i *+9:UGЄzmM:@E@=::@G@l,@Դxa^td_\$cBzEI1FL T5|\] iop^Э$4%]NʱD5XO) @ ?)T*rc#O=Z PqEU@Ѱq5"^EU @@"P[@79 Pm\+%q %qR@ +V~uWqoGBqj|ύ1.6KXOSSjT2Vqk4\ .j-h|٫_Jjl@@ P- Pi"@(ThC9i1q|)R4ߪV3UFJFIl;4)k$e6W:4zG0P@@ P'ݵXY/66JzbeA]֖ı w||*S]UǥoKGBSWʺp1Gẕh@@@5-?Qd@u伸 ct+Mry5%2Tι.nN@UdO;EJM)IUiFyѥԏ4Us~a\mn@ Tj3)^1/+-ru'CMa=3]'=Lp$3SIGԉM/kЊǔ1@@E-P QN} Fh;&v?nlxlm'Rqѭ@*ZϿOɵ@"Png4RMN6qTկ{Fʶ@Pe;'_I"PvA}T@CşIDATZ@M׆=Z?^˱U*6?F< Tcc}ꪲ9|O2s}el3WK޶KNcT*Y4 }$> Pʿ%h,3Ԅzu]fʤ/9F z:u>^ݧ9vFj"P1yDHүē3iRX*D:͘S"Pcr @v@ @*ԛ?9m^Zj~DL:@* P@W*ug$^o"eO@Dږ+c.dJWS*[4&RaSxJHUH]]2"POiFĩڀ*)$@D ThTgQ~p @mJnѠT* @6`pp P@L;gFmP\f PT@BB2Թ@@@@ H@߁w4CI/Ů_v<].P1P:/"PN@ Λ&@* P>@l-ozFlkuxgĽ;+PŽO.ϩOieFuke2/.77TغCl_-#Py|={ cj*-,PA!j(9w:] T)sT6@ȱK[TUd6K@ T'j/ŒR1(O!ȾUee?rm~hND%x[Vo%Wyv[x"Ѥ:u\Ry?bi5uX)Z@5@4KO Pjdop}*;roGI9+lIJOE:2ՋK7u>@ 0XNWhNl% D֘12qk"P))GFR@z> *@sGߏ_ۆijbX OGs_A"P`5d|^\_d(P 隿@5iGSR]>l$huNF9?꥿SG1w zF842,p<9 PHUoEj•TITro.Z_դEX#P {wa-(l22={T Oq/,P@ dSwn=QhrorVݏ5]fcIMMnjШdAFX-P(,=K)Q*#ϊ_=ҡ^ brbLLq"NeӞ?T(n Di椊E=uZNzPniTgz@r -t[XǏ5"Pqc'e D*Ptkk9G@*Z*6QD€@4j"Ru*Oǫo U@E@E@JO* P-!P{zM&R@E P) PT@ P L@* 'Pi"T@ m"Pν9@j6&L*T+P}KP#@-C)#PT* P6=Hh|eS6WzW1;~@* P[)&],/6Tj-&R@*џT* Tj!#ؔf4vS,ǥ=\]ICg ==0Ue<=ދ5Suo]y.\IF.y(/vݒ=0Ìռ6~kOk!v96 T@Vu 6GE|:h3M TSpjBTǮ,G;Ox TG𮤋ԤkA_xs>6"WkxuphBpT@ PWQMNH4*r/UV! +;sP$v^צ^5MEvV+Pe8lFhz S@@mTeppLG?ouZlJ2=- jKIצ>ՌTqs<_:x5yj-T@"[Y|jo@$V6JBȫc<"չan5g .7:Ш{'fi돵d^I]QL)&j%]juRNfʾleE#b*4?M~a@E"P¯5r -HM mUoDf}ff*'"R㯍y]Oe>V87źݒM.U9fP1 tnC*M!P\Pl_[߮HJ$n;4zY.n͋ke1y<ϝ%e*`1+ >K-cuSr%8md7߆J<ԃaq.:̏s>?nj@k-v+ǂy{X_;+^ՎE*@j] TjqSWb_q5t%W6zEfc<Qu;s(Mܪԙ;Kp$!Mq6"U 2SӤiO"^Mj4ۄcv>"}jjO~*@ !ɛ18 PTj;Ɔ*̙gmIC7>pE6G PZ^C)8 PTj%q7O|- ]@64'P1^ڎ91Ir5*K,@"P!Pk?Ʀ+ZTj() h Te嗗@M{j)@57@U^7k"jF Ej'YT]RN71S޼cN9F#߿*s\XC9HS#TsD!Kt* P T'@-@|bM;ԨZSh*@(2 ݆=NJ )JsU>}j5*"H"[WZ#r3zc*UƄUƿVi"徾~ P FnBE B{ ZKZz*i]4:9UoJDJkzrnPtWjwC=0&Q5RD*PdD"P_2 PT* @Ў'u?@E PTjuOʨѢj) PC)@T* P@* T* P@@5:84!7A<"Pu^\[7O𙴕xunX|1k@E P8,@* T*  %qߣ$.-/s}e"P1kKU+G|znX\볋3cbπ8㏛ki˺`.wu=NWcQ(@@5u1uZKXw 9j].Ũ'D-;r"MP)3u=M?+ՔHSM8&*nm 嬜)3* i"+P伻8oJ%U3e?|Ӎ:_ρTCUDj4j6QGQ#Pk{{ PT*[Z炊83L),O)S(Q35/DcT*&:19rw!PT*,MOwK|Ynԩo *cj@|1#V#P^Tn+P)J ?@OoHo7'~wx3@@mTIv45rD%-"L_Yjuͤ"R4LD*s%)fT׈@E"PS88+~%gO** @*fDJn߾#ff~|TT*T'|*P%E!@@@m5F@G<~? :)4B P@@:oΜy?"P=*@"PT* PT;?9q;brELZW:84!7A<"P @E@U4B PT* PT*@=.6֖1>}%$Z Ps']~t@@"Pо<:/n'D.ƝK5q}ϵ}WəlsKҸ1T*T*5wJ)@ @XٸJ$.뿨H:#>=7,^_.[\;=&yjF׹s|i/>v&G./@.T* T@.bw%q!m3u@Ud?n96q UYs1(I [^떅~#'xJn?$\]gsQcn`_7)m\פn )"P 1Y@*o(ڙY P#VT Xś)I=*PUg|"P\s5AEE9MWu{q=NDz@5Ӟ2QqΩKLW-Ӯens_n000@( Ggg1r*i(KSfa1W TE+f?G|T]vZm$5K?\3Aj־3SSjtt%$"e2 8y7k?{$h>oKE2ke s^aZ~~ > "Pm.t/_:fZ_'gҷ}77+?i  @E"P[ -{SbҀ)[s0J2ը j`@oo3"dc9%蝧ER4f_zz{\)7.C/mIVs#@ٵ?{ڧԺ͏@m@*4N]3`{.@BDS~-9a)M'0͒Jt!:/nWvZo-c]1R}tە%qןΥp eyDJ>@2UYGYR)\O{_-KޱQss*2 @-@Ǜ Py$u!QHTU@cxQl}T;4ѤU9&Dڷ 娺c[)5ۦG^L@u"*Ѥ3KqCsDJa*.DžǦ+zlE"TH!P": zc- `- к)1H|}@->N}GDOϠ+^r>3@?8.7c))k@4jJ ߈F{ʾXL&Uӈ4A: fDz P[So7eLiIu_| :~pkiRޏz`{MGq,{]5wPYZy5Mh?ҟN\\R@E"P о@I7>ŏ@U;ISDTT"N+"PD^_aʸ4F XVMi6%P5Նc)t486*bCKDꇖz'eC`fvUC۟yԢ 1u@MGozuE1cHUw;FA&ht#HMN)P#IW&z- -,PͮKJ7O[gɷ4j\墹[2{g[]*RX{lnЬ6?8eM3ܕX"OJ)* PimI\ФHSMu^JQvVKo?5UuS/*ѝj|IH} @-@m5F֗?lDeoH Q>H<= PT* P4dLy@4"x\{@H^+ۍ}%$Z PT* g?c62KFUzX5̤ n?tՊ5̵ӳf)o4sM4h6՛{ڵw̜sϡ-N9fL:j1BIlI.g1IXwזıіץ$.<ϼxWəmxO@E PKMMT*@@?d㇪<:tě#;MIKt*Ǧx2 ғs{O>u>q;qsT_8V>v۸n>R婺O94V;?@2z21T~gϠػ3ٳ?7;TH!Pfj%uP['%)BbdZT3"3-Z@}zǡGU׍$lIT?9ؾEt,SjU#KbeM./z7t^\N-?/zᎲMJvRtYQ$?Wbi7W_W|s_J,C,)S|Lc.'ǯr%Xy޴@ɟ#ԡI PTja9#~@RT*//k_]gs[[T*ukƃ]o|)R/96ußb80R:ZIR)9Y8~3X[F']ӤMG\nnV&$#u~MSquK*  UT"#(j(}iFT6ZFۗݰҊeJаƫ~ݢ{ʵMTG~*P]iD Pc3"c=#PSI쳽Ԛ9Fj& ^I9_/}u["E#i͠D&i'5 S].7"㯛qPUTLN(HRELڢNm"H N}2%ܦ luZE Pv+'ݲf^/@mi*U h-)(O)BSBb/Iq:(֕--Zg;ĭOl3BTU:[ޗNpŘGYxfǾ Dx TsT։h~Kt1/PcK ǝkFs?3 T35{S_z@ [Y+PU@*@E @mT52+*\HSGyR-Q>Ӿ}VpʱTRGSJNUj8hD+cer3t#PkЭEb<|^zW P-Lc&&Rf:# SJn 6~*8.uLa9&T=40u8JHy5FJt*_Oѥ83R566F[' Hi@E*{FT~ P[QOjk ԸQqAJv@եk)LkMᷤ%PݡDGj22/+FBv>8GKN8@ T(+o4B@5(b PeTIԖɯ#·~{3ڑ֌F̆С/b*al{<4<*ڢ՗Y^Q_,:Dm+˽¯Yu?^-5FU$y ԈPC$P^/4RU!PD P &PJ#yӞWawj@@"PGjQHy͜nKc5Q4܏[33@5\E%F(ǠNO4"P(T)B1d7jf8^4E<ߏ!P{{D!P3MV:IRݐSPiinm|1*&RԶj"9#?(=.O8_-A3@ BDj}M UkDy@@b4KM!PT*Q#Rw|WhQ%ʔTgğ_ӳ/:T*{wՈ3=ht:NOr]X^w~8nnTeL6T!PÔ EI|6~•gĭ]2 fK$Bl "EuZi&uttVӄk@E P!"ON?8yg? ċmbzML!P=N@MTX|ÊDjk۪@D*VMAWhj%,ѡZ YG1czmJs@"EOg~seGw<TH!PT?:/U\ԢU @Ym̐Iij /܈iyz T)6 !YB Tq T5)K)7?bT*M[\kKX]3/nwAf!Ptm{>pcz>pċt2(S~(' }ԗb+Lk.eJSk7]Wi˙#PqHE;c,GwU5PNtk0Kׄ1կZ&Rq5@J~*@Ev@](; aչa\=:]+%q/(?'TKiuptڰot  h0FᖸYD .?.=@E"Pb i)MCXw ,OF PUyߚ:@@6qUQPE(S@"Py q Z0*eb].n]Fjc\,H6}++ 3ܮ@hG+q?} TvP }T Ը2guxG,3FPG#;ܷ&s|@@ PTHu?k⭷~.xǢw /tiŻr3 (Eu^_zs{_Jʅ6o50MZ_tLD*c\x G?~t9D5nm5dUR@ָ l8Rnz97[K/5@%sJظ @hK:66+{S瞂@E"Pʙ3>[ oċGQW"?ѡ1JhT?3mM08_FRٓ,ѡ䌓)~\Gԯ.c^ T@dh3]C"P Q:3Uf\|{ cHY/|$~SȲm\rμk][s~;55REuA25[S~'m_IK:֋Tt8379؍Mt*P}O-@y:T=U̔1Vth4ud'mԨSPJijL)95ockb(ue) n(Tw-],+{qy=7T* P:R|m̐ޯ;JٳslU"PU[?8{RvYs9x֭*z']iQNEBiT(PTs[x@ 9ѣJ)T@PHS&Res/DE@oAYd' TC/PMaZ{ $?7%@V(P|T* P:R':V_9 *C 1Uج#P5L LNhg PGzT@TZ$۸ɯ=·g* ԉ8կ~ýR;I69Cѣɹܾ"N~YՌrG P]2˩;EUtu7RU9ǭE]ZBFM P ԈP*]$P_/U4RU!P@&Rssō? PԺ244!  @&RN3'U,۪XrMkԤ 0e?ŇUorh#PAj8<9ӈ@UPjql@V5T uD"PR /-{"LsOA"PM @&RVH T*@hw@m:::5F"P@M5<տѢj) P=J)* T*@E"PT*Tя~*zx9* T* P+P&# @$0u? 7%o@*4B"PS @E Tj.\*_(Ӷ@%ubrm*PӟshͯTro#PT@ P@FJI=wI_n)T"PwW4Wb_1@*4VO ?)& ںyߋg w\}>ym* @E"PTـ@ELʚTH!P;CąPn-c@p NĒ!C93?&>: PT* @-.bpp/ ~V-kG:7 LODXlM8FWi3{^]$VZs,@ի31 uS#6z&8AӮi-77)omz7$ƺy4~ӫ[ŷ̀@ED @@B^҈J/̔#ִZF:T>7ECU'YSRYԭ)[$ v,UdBq-mpd{c|{1Snlo3~ T*@@B gN*~_l@u>3FTL9ohc*@; T[=P3FwRǏT7M1>kN2uph"W@E P!)N~qF *M8C~t@ %g؍Ml`H׬5* P" ؒ;kr;뗢v1_T7v<]ԧrcrܛ%Ջ\ \Nd+@@"P[+b=mʦmw,WsMhT* PD^F4/FЍTjqp)L Tsf TW}f)͊@E"PTj;~jұAJA4T*&R(jMwSYj.в ՉDo(Tj[.D62@E PTjTSzfJe.?_ hԨL,!`DfZ H+ J3 TgW"P @-@p;{~%! c 떵UHt{,~~O<糥q) Ue˱WVǣ:#NyŌG?X^"cC9XW^F꿾NI_sRX=l= PT*G)=]o"h)R$SmGZͥ.dIWK8 taJJHl".sr4Iͥ@E PiJ˜τuTEX@U%GvUDc)Ǯ%)j,ћlRA9ϡymm<1 ,2Xj΀@͊Ti8@E"P=&#U*N+fh.יRRo5f]-)n$ED ܌"J;5ӈ`BF$&-^{8#b9u;yTaO\y)Rԏ"Z[\΋'ogKGčA"PTH!P[5u#amhn~5^4͔tϱՌl[0p?:z% Z.qw@*@E P.P/_[ZjԝtS1ex?¯>DUYV۠H+_DV#Ÿ,'#W33kk6AGud^J"%Z@ASkmM%dfD.]#O+j-Kctz4_J,Ch.J u}1Ւx\.K[7@"PGCGT*@ gk }6E"&S_gjkhʤԶLfɁ9)iܹB3uQR^n|њs|Jԏ'IV#PܖƯP]7꡶@5:ꪂS4cئKFz_enẙ4uDŵG TD5Tv6$h8wJmNga9.<6X\Kd+J)* Tj7(B#3KIB3|ltuPEe)Lsz4o xO{+=H`lFd@E"PT@ PuĬ .PT"P(P5A:ia|TsjV$PiE"PT* T*DREU)PY_ =784Yf|Q2"h7=T|A>!P[NʿOs$9EDj~L_֦ T'T@ P*PO8{FDdv>W.DEd{|he> Pi"@mK=uڣ6.@wlz~M&+,Pu_'g;@*uaaPphoY ~@E"P[WZז1ԝ%%|*CťPM~8L&R2)GH\ T*f&jnX|vSއNz3@E"P9 T*@Aܰxc>r+tfݎ8 ~oe~o0|4B"P3F4< T*@h=RE?4#b^)@E@q @E6@K14<egn\XXsA~qӉ#bq> PT*@ !*Yuԙ9].P7>#[M M87ă-q3[@6E@uT*@%+' )P!P:u"4@}KRv;u[+p_;o<7}-v},"b eTH!P .PW* b esx@@@ FPDIN_i(PLOI5u&fT**~k9sM}x@z=Sd6O(HT"H<](bKR_ 8#х "5cŖݢE|+[C[@Uש㶅Bt3ٮ`{WZ G:ϽTZ'@@6KʿAyGj1G8Q8DO={DqAT* PsDH Q~) PM4*T* T*@E @@"Pso#@@F6=CT*@@@##P@@ PkL)@&ԝ{ PT*@})ԉ@@"P@ ?P7 }t( Puz@꧕>[MT*@hoz3ysIx#P ~7P@jj3*@m TC8z4&zhGҙԗq5qH)]KW:z׾l^D-AGX"nYj}I>Z&1bT*@E&?&EE-Gx%̋2l׮$x>lS4{yf&?6XEI|T\gGsM3"xs(1&婾^tauM<[Tc%"^nU Ըk|ݲ2#2Z7zM,>YWYPajrH: PTj"W>DRi3@ZA,1"Y@O΋S'.3:WG6fVu ~q'vb_|1=}ĔHx8-n_sKSGNt/>^خlnqB;p{<@ PfE,9UjZ ϗeA*P6 1)RV^,rn [T"KgˑD9ޛYMj-w|m>[mgTЍ=uye~} ZO*蓥k<ڗrbl"LԕP}U]ϊx P[+c=#E*]#V,QT*ZXa=IS7SgqUQ/4[Q`I'MO_[WҢRjeSmz'PTrD)3N@`*ѡ@Vb RŨUrVZ,ǑQH\4>/Q&:'[=EQhk2#Ԓ=Z*bǓpT[jqZUDg8V*ԆGͮi)QgV4D6>M*e 9(Q%Ռ@K:ѬL5#PTj fgT`k K$seV@md )CFjU[U_Ltz-ޫS&Pq)5T*@(*f\j.՗OFoLF4H0_ܞט&RZ3D"P-/Pߠ<#r%/LoS\qa>5@* P&Pi"ԺT)N݈Tj PT*.;=oPhOv{cG@*`o9^;*wWO CGg%G{$ @e@m@m{@m;vuյeˮ<N8?&hKFǏk"aݏ@E P@Esz[+ ;.:*;K, @ -vmm;E>whXiqT@ PԺ?~8S3{q_hLy*y@=bd. E̋T*MQLvDT-]XzqvE{@ 83ߵD1%^R/Eg!gXY(V{XߗC86@=kPSjf4N?4)Ո #Zs(P#ѮE0_E}:U TuxL><k˵N*T* R]w^@;[N-PĦOs8N^8T*3Mx:.|]߅\ͻ#(ܧ #q{}N޶ Sk\ P[EqR3@MMXj$PpE@MNA&ESV[4 P@UC%aǎT|KTjAj*rm@ME/m4}QS0ՕV驤ћ@U~٤MzJ@1Q/}u Pԗnd*P:aIi:Z@M_. M_ YiBT_˟ZRٶ[@]\?c^ϛb58 SJ&V8㮩{Wm)G`^ez~>7s0*P8V/qqpsU&RV;~$P`uo}-@rg9Q<#ПH3]zro~#c@{sc^Q7-MS׋[GN_Tٗ/Pkjdu*?A+5MҊSDtGZ4KKѬJ;8cX-Fm5UiMP/L_"R&Ri+0U?}Ej R$ Դ@kEJIH,G~DmsD*y狲4 ˉBhC=}<#PPC`Jɩ TgUܽ:XבE@lESu,-"Y- PO|h9GI ,͔I5)Z5oFgѡD Ԉ܌@ΒҞ\YV"LW󚩯mUF@%yhۨ~㎼D"PT*:y FdjMuUjkDQᔣ$BX2^`US"ӕv@5k]c @ rTT?W T3@-@5z۸ jբ\jiq)fg$4F&韇y)5k,s@5j|MO +Smi'I獦V/P;(z+jTSHbj[ T(@5RݨCugFML=Oej%_})e=O~^+4@Mngֺ1o,$YҮY Tj9;*ffQ7ngF)fbT ӕDY#V]x!ĨauR] .W#t=tA֕S@OKP#˽qS嬺 WTqԪ@b&"P-uU}]$yT}s#PBGY͆T10/1uIԂ*P#{3$OEKI Vj"/*PgA ՗Y^aO,:Q74C *gxTYj-шH0^WՖrQɚ@-Z@E^ji"C"P[km瘚EŌZmR= PK&PݦM3|_</s-":c{4m|}bϖo+&bU-?Vhg@EW^¿>~ T[{)"Sm}YOT=5=m5R@9u5@EZY(Q#5\/~TW5EM⢑zg)O@E P-"PiNT̬Wx4;q+M_YC,O|%^&<_ڽt< @-@Ng8j'nݚ䩬:6~l}M 'b5 PTj TSԂT@ PۯUF_;s@ʟxh=@*~t/ѹ"fWsѡjD)o'q)9 T)"a9kyiβ\-+uhZ| T}xI.?6/^\pQN3qUl0`Elx.P\@E9T*@@52蓂ӓ3(\ '8)P# U^wjVPFrߺ*e\L#=Co;%XcNϔ@ Ԯ==ݐ}nT* Tj T[d/P)+UiFt6ZꑡJ4MkGՏ2䨭k"P TS_>Rjdyc:<&ib ?%.]svhX0hG]i< Pi"@E P\/OYC@"P'PmIՑlx#Ii;H)RhDjR]<jWŸa?g:@"PT*@ͯJȧI51y_Ll/qYJ@-} aA"7P%ScεPfj~5t#;U) _MJIzj~qc^eim?8x*@@5u|\gC]!Pۡ|S'&Is Oڠ4q|+` ?,uSn01GY-RaWP/L_"R&Ri+0U?}Ej R$ Դ@!j6cG!lpmz--P{{2@@BaMlbg9>MO 3CNgt5k*.6{ Hx8=p_Kܟ'-\Ymݕ]1SOv-$@7==Zf T*@fdS)Ala|4B"Pаkbѓ "0cdTz&-RO(F Ygj#PUa)JөgEUٞ.U}gGv?+#5kA "F"PGXU@nA7]|Tj ԝ{(#PM:RE`د$"!4ɮUZIVF´E_R?{bthDjOEѩLmP T2!P@5ع@F3x;< P[QʿAYZN'nKҼG & M+P]QM)LaI@u$"mTWRK/Pv#@Z}MoߨTQW@~R1MI)뉞?G&@v$[MR#ͭ,P}{QU7/v~E~F TgxogjT#PK(PIhM{O(/ tЂ@E"PT(@:˨jIǏOێo6jrPBMMOZAfk]~-A#'C*MP5Jk"eOL+@E"Piٵ;رZ-Nl9ٸ}^8SuA j288.>#PTh@*Mܴ]Zlξ͙ʹ TK[ t\Z^(Kѩ.Hܞ|OӶTs=8J)*  E!*կ~@(07HSy?^4,/PZ4j@^Ri{*@E P @mQ{'"D6.4-[Do~1:~<LU .Kԟ-*PwK%Z066E_^P㭣mDz Qh-V RxQ|[!Ŋ7-M֋[2MWp!޼T}Z Pg"reЂ@E"PThbnE{q2B,*Oȏ~Ȧ5~FT]bB&]-2Ӝ.}(PiֈCs=GPvJjL)9 * D빎`U'@+P7Ug)~ĖL Z4B"PPO.^e?q^?@Y9UrqUG hRoXx^LeREHE5_t)j_u .PmSLU'P#P} P%r,H1ͼܥKWs[g4}-r䶲,WzF PR=i{E;"g*SjMfNUqoIٖ7JqztQǝjڢIm <ɩG4rIzS5 3e(O@E@5bS8~8sex4;ncfEYtv]tNM>}b_oO</ZulWr]ٟ7o˺Ëv,:<@;SS"T(@:aP&*0hA"PԜOk_˭΋G0՗kP)3u&MŸjJW[\&\'*nmY#}F.|,ݿ'NؙV@=Tjs"RuN^/zBZ; FF@LyM\Yѧ= ?S9Jt*PR3UF&jHW3jjS@i1;{ PT@Dd]ZԹU# ?6̊_ѧ̔22U5#PN4k@E@*A PԆ2$j֩^eٹ6j@5'/*kNi@uKAkS@E TjCHyuPFoL-?4hZ/nOd@UoךQ&@*A Pi"@m@*< P'*@EzԍHmڧ@*"T**ZQE_ jOc< P ZluppT(ԝ;r@,@h@=PZg@ p^=}ZnS=J P@iR@=rN9M @E;rhczjvU<TyȺrb_|1 0hq-SS}?΋[E{:9 P=ä#PSj'T@iw[4B69yx㇝Wƶy{D9tM]WegVy@Mu\牷|4U\m[?ʹ/Ysgm\Yz,3259͞nnqB<'N~u.n_%'FmѡLFѨ~}?ZĪT@ P3h94z*P񋏹 h"@m aD{+J$4Md~Wnm"P)@3dPU4+K9E*Qܔ4۝Pz9 TG*֌@E"P)qp_q_A"PPw 5~#PEKw][ =nq,*NXL%_StڍMr1)ߖO* T6H;pOA"PPNU17k@5pL9SNcHuP(~Tdmͤ"M&Rnti 5WM@EA P *P{{2m EN/5 Em5p)<*%SC 5ڧ@Z0hA"PPn T^{-![hcfIi.^tVPXp]sei-Wv {zcRju&,@m*?Y TE,UŨJ^ݞh򧤄4{/0Qnϐ^ug}kux(V-Զ4*"T: H7JKIZ\[*uT"@FD#fat  PT@,@=u^,@]P\Wڥ ܜT4#P d@eЂ@mg:88.>#P  uD΋Z6?%SJ%*P)5ؖ#m Z|/MG4\zE|{SZ,N7@j4l*壧_F.E&Rntm 6Ovʠ@EE{H4YvWE@N֌ZA PT*) U@Y EN ZZ J)hM:)* k`tX_O>,'UoK/gfnh*疞 zg'2iUY,oJ !:sqR;K[+ӷ6y"WmNw91ùʾov*)@@`T*c\|fK{z^ZjMZ9g|5i+1u*e#'E略">]-IfNWzC$ Tv@DT#9jIDAT* ^Mے߮[/3Z@"P Tj[S]X^c#$J,Pز`U Tج$YtR:B5Q*rUiUf H:"Ѭ@* Zl*?3ZB@j# mP5VFE6=]Ƥ4n$E"P@`" v{/9=_\SXW\V[ 1BR{-WZtM}sیH@nrkV95us9rRfNQ6 ʺڱǫyU٢suOcԅn3߫,/ P"5&MXi)Uke r Y*ױ2k@Y U##7z|3L)`T*^c`\户EOp<3%'R* 5Lqܮ'<TOXj?cqhp^Uk9֎fgv)-Ou֟eȵ]:RQ& jDQ5 h']ښKoT51U?Hi Nկi"ekL7GR!PԔAm8$2Zj BrK+AH?' Z*jo9tZx{eҸ3^,Z"""B/_2u-I[t?#IU˗w .PQ hQ=S[d*,R$މ%3qT=[UJP'7[ K(Xk(ԈJdk&rK՟Y(oj(!P#UKQn] m|Nv\URaZn!6&vqx8q[91/-\Yn+忘mGDe PTjYJ)ikgL3ŒZ)u_cʳ5lMܶ8Z9qǺIjB9gy-t͜g5@-&zݜ?z@46Kڀ,v iJ1Huk/ITj(@U 2WK1)~[Os Ba)JLnϐ]:4QnQ ZTjо8_*8R .W CFK&R .5T[l@J0EZwٮ{g< Px)@X&Lg("0I[@5^xшIC /j(Ydv T +RWZ@R TksW*PGPR?{b%:TV-5Fj#VTU*MP[Qw<@5h #ce*UWC5ˀ%rQ@ՓFK+ei#*]95K)9iF%PIT3ʠ@EBIH(;Öu@Y<#L5-e%@T%Qɍ̒ޟ$j(Y:VV^{)k 5HN~Y=*M.P]!zKW委k7J4UzSj-G"P PTh%)2>߿[1Qu 1@>WņϿs-xi~c#/O<㏭*ZMbkY%~5 c]P23xUÉp~5R'W]wj(@tT"JyF=T:R>v,@9RTm"6JѦPj)k d7 ZԀ\1"PgH3Z ޳Gیe TEh"F&5ىO&*@D @ɄC$Pg*Mv@"PP7*KY3`eaLjW8̺H?M|#~ qܯ)@dit@ L 9jjy-꭫(խG"PyAT*{@A@u$sujy T3]nZ )PHTxAT*eꆟ gރTx=Z^F/ Դj > Tw_CxATH!PTh@/ΘMV(Jm>%R4\~PM:8uM&R45?Ij'D"M*o}L\vш8p?N=&0:8Xy;"P3iZV7 ZT* e@YZ3gϞ X -@)2JbvL6jAf@eЂ@E"Pܷ Ԭ!JE11AD|:"zw {Ŷv@پ;߉%[jjRqY PQz{2m @@B}U2|{ك@+W~KB#E/QB*ni~KMj)l/U޲F%m?JDkc"Wϰ/9~ YWs"F:ZExԗ/Z/8{V TH!PTț:syBⳳg EU+P@rFWK),\GD-Ukxi,C2>JFnJQvN JS"P;r_REe][]4,vTjK TERkgXY(VUιr!PACTkw^VP397:ߺLW^HO%jTZ5i5j ][ TSj4JϘH:@5#f]Tjf9*5R=hx|Si@^B{sFNd/c@mm*Pc3`E6P6j@uRyY"PeZ@u#Q}E"Pi"@%E6*ʵZZUHW*p\-sw@T*@kg@݇@^ICSnAL:~:nfGAizq _hjD:qTn#^|ffi@"P T)F;K:oSzFF-r̥WifVq`t]m\K[~ilGY쌝_n7J?LV!@EW0T:55} lhQҼNVX+}[ڽ#GͦStE#2rFi%ʯR6DLWTyuj|+@"P$P9^cj+Gk@U#M )%*PehD+cir+hNSͳqY#bBD #{׶mhKW>Rt}_ U{A Ės tZց/U T~TH!PK*PJoMK>U`]@S Ta-|*w T_@RUqkT6s x>\_NuODGĦOӶ>Jr @*@mQ*@DM6Ƥ7@ښzjiK~oj98{F?GPv$TNeV*엍yRn}/EkD[ʺ*T쌎w~*M)PÔ_%:oNOxو@ Pcul,PUMlr4"֛)y#j Tj#P5)%-zϑ4(PtiUR)4#PCi9R07SFqq:fQG/ŦѢ~]w? yQvuAd7wi5w@mTȗ(= Po@m@l+#za*u@5kVOᏤ&P!Sk+_Vir-:z,qL*@u6{DrmVNZ=2ZT&&P%)5*u)ft4vѧ=!P[VʿA3 @@@ ",;C&ɫ)|r# ZUDWmY^2:oFdEv_)(]&|Vkj")@G6o~A9@?0S37*MT*-Hu#<&Rz!Mmͥ//d@Mh#QγrXMjkIHPRS(gd/^)@ >i5@~Pfz}u"vt/|q*@E PmD ʂ!C͚P*P%'OTp9tT=Ā Z ;PLF"PTj)_JZHOjM3$?Y7$#1@!?w@.0PCD @@ P@@"PT* T*@@ }.g{z ⚐-&,sx. T*MT*@f'JT* PT*U/%~T@"P T*ڞqHi3M\e#Y7c$Z&V43nBcDᆲ?9rsaM? ks}޵X]n1&OuYܵqΩ~/T}<7c}VV{@EښuppiV)A'L@8#PT* 4`ĕ*?+Ff2l%)n34CF 4. UP@9,N:/'jCt:9 PThNT_ <vS rQOŜUX$SÚ֜H&ەqvh H{$:Rq>iQ9Y?WG4.SIצUM*)PC N>[A P4B"Pִk`EM4i0hZeeIȒ\6㰮gڼ1H,PkMlZz \yPTy_>?0*@E PT4O}% PT*@T_~b-eD @m4Sbӧ߉mbeyX9W::ķbq1W5_;[J|#% P!(@U~% P|*@[R~Nmv:ř T;/E3Mt\Z^@@"PڲԩSݻ_w1*@E$P3 \j\[HZ"P"P@EXᮮbd P @-@lE(O^)Y8}I:.|ilK~t?鑏e1۫A޽Q|Q=soUܽ1ps1]r[+*Җ&fnQ浦@o}&@**J)j#PUGC*5*3]HSGy,Q~q1JQ~Xj@U#M )%*PehD+cir+h R*O9A"B?3߉]Cbpm14@[28?"jy8Ȃ.qOLq-T*S4R:,EҵMᷤ%P_ڪD@@5?Ή}Z8gn>W{@@J:u3ՉGf"Pl6UFD0yzy*֜rص{HhKvWjAj{ⓧ5o{Xyeq_fU̝ qex4; = ClNM#!pb8-sb_|1-[湲4\ݖ+PoWs=11ێe><@ Pi"@m K/4l(rorVݏW4@)#P@նEACJYN@Վ<śH~݇4y*yKl&ύoCoo0Օbԕ8=5,'+|J(L#E]i=)]Ru+\YǓ= # @E"P[I_zz*fQ45i~*Tr ;kjkmIN#5B"XǓUoXM+?@mM~@όusŎ{ ^EEjxj ѧ`/Ϟ}hUej$UF~0:U@@@x%h"9#?,=K5yep ^7oԹ%5u?=ʒ+S,}tԤ",T@u$m ST*J)*DF#d-D2FzT*pPǎ!P]!Qk:bQPO?%SJ%*P)5ؖ#mT*@@B>l@Mjvw PDke6 ИfRf&*]FBM)D"PT* T*94v̹I@@MJ)* u5^+Lѝ.#NӼ1}qu(5h:ES*@E PsGlE )ӯŦqSN˿nD#эIbTߎ)+P(]ިE7͌@*s٘ QK}io37(/Pkjg@5@EJ)*Th`*\JS-EQhLwd'=G4]O=#PAHSC`Jɩ Tmg}3z4QX;ZT|g(S 9 Rߩy PT*@m@s'J+KˣIgکUd+vjrH=⋋yO< ?-#Ovy}b*vezmWv&cl+q?T(@V i׺ FT(P 隿@5iS-)y Ǘ*[Uu5>5[2 Tjjo9t@u#K1*E/M}8Z՗+)0M}~q'ND* ב piWs~PF)[d&6*@DM6Ƥ@~=B@]3+ iCgR$ @tՋ2UJh':@ue\2bԗϞ\D3N&q媲UZ@ @H9xQ%_~h¬qݨT)KCiڑ@U_I,"WnJaK7?Fj^Ց5#Pvm"4sRĢ`JVZC&SZva!c"m#PAk8E4?/Weu:RFֹu5W[+gyX$r @HMRO1f4r2G&Rn4G5M*&@E"P[ic{ѢE(S;FLUp5A-}GM1*U`my̙/RD2>JF͋|ҔTsκry徤J8f.aT*Z;3 TShB(L7iUuUJ-PM6#G+=c"Mk@ՌuA"PTj9\@*3X @^ևDTS:FrSC P-Ps@@Z~)9tx~otZ?@5=ݍ8Uj/g45"Kt8I*/> T4 P4B"P@@d)(yqEF!Zyj.U"Pmin$Ko?ղ]R8m8F+E3ԫMj62(Um'@mir$ T* @*e* P,Pߠ@@@*&RT*T* PT*hJ_+^B@=pp-@"P T*ZT2Gf -,P].:Tj Pjޛ@* P4B@Mih@*)@E PT*UJM?:T+KGO4I:)ͳ SZ\vi^^~D~HNciceW1o[|/4qIcybC[޲+`^@*Z*6IP߄#PTj]sgRlVQ#jNur݈@U䨺m}Y#7Cq% eɩTc];N'In]uخ3 T*ՙ:};UDS,ǚ0Lw.F"Pe@-@5Sf$FQ7, ]j@})5בjDѡ Tu0#L P @E;_~ P!P @jt"CmQzfAT?B v @"PT%3 TVCМ31#)=:G Tpmg<8Av sǺ8SҘWW_w~mL}m8>qN+GVoo{G&T*ZPm17VRVmoܬD)O"PR ꏷSU nԧ.PTJOE ,;@*Z@ HOPk( [oO\iLS6K.Bqh'}Sm@Mjz J@ P#Pߠ,@-w xU7 שygue l6J 8 5_l3%x2~Ij&Rq bQ 5 @@"P H" TBA_o`Zܷ k ֢E0jMu@ՌmJ)j T* PY~Ј{@"P3 K TIu6Zk TSF6A:ZW)GƧ$@E"PH)R>+Ϝ.Ps3'&۹7!PKoNzgjpra%pʹVLͯFTOw#N)=@:H鵘 ԴM@*(upp\|5F"P@-~n*Ze)3?h¯.ZW$Ԩ 4*+\GoV5gOڇ:?b?,PmVDJ%ʯR6D*6/nN@o|T*MT*(PmRfk T鳽!eS&5v C't:k̍C&t--NȟG顀#2rd}џ'mnWvjooK;p?ʶ& T@ PT*Z#X$g 8R{q*-5=.P"PC9?tFm0M}~q'ND* ב piWs~ڦZKt:CoB= P8@E"P yf"UFuN:U Fٖ~u\C%-c:1KJgON[CU'YSrUُ*P*mA_3p*&RT**P=83/gyqE$D]*3Quy+*)i ,TPzy TG*֌@E"P뽃*@+Ǝ7] @E"PTj&Rؔ=YʟWţJ?RM%4VhQ@1U;fOMMbj?s T%&PMUի@E6RJo:7;I׎= @E"P+P-o@A_jDRYFjG6WfjЍ^Q*MQqM)aʴ "PT xA PM @m}?(;r T@"Pڀ]@ED 7ǭs[Q1r;05i" ŧ͵ڶuz7ڹi;.Dߞ@eT* %%>l93@ P&P#uĞk]`d{Ccyo8%@"P.P{{ȡTu"E*O}>>)lGTxyRF3@@q{ P4BBy.ZdϞa@]CTm;ǰs^zؿ8u…bwbd; FAGvfT* %?X5Hա+T*<8n) #@gϐSԔ|[@@+*@F^D5 @-@=CA5OձiϏYkbku`pi T* F&m*v Կ>LعWu=t]}AlP]3T* ^Yta1t#Zc#% @-@#PV~A#P͉{8hgdmq/@ P %Pi"?G} =ȁ]=)h<9!~/?o4ݻEw@[#PT*3.? :ukTj{ Tp:9 P>Sݻ"vs @v&]p s-U6*rkT* ж;X:005*@ho>#Ly_kT*M #Mߋ?yIܻ >T*`D c @E"P*w ӶT@ Pm'PywZ @E"PTj9ꞽ2 @@@@=T* `@)@ P%9^c*Tjij5 T*@@Ec @E^T*/h>.8 ڸ@@ PTH @-@ݳ8T@ Pm'Py8z  PT* @@SCX@ P^4q  PT*&R@@ PT* Pr j3*T* *@@Ev #PTji*MĞ@@ PTH @-@Qr3 T*j5 .8@@ PT*@E PTH!P@@"P&R@@ PT* Pr ށLB P @@@=CdcpprkT*@@ P{tT*v&RsΗ\ @ PT*M @X'?ޛ8y9Irr[Y،Җ?m @*@x T* P @* P@*L ǹ@@Lh"T* P T*:@{kT* P[5!?7x@@"P@h@P=__XS箉LnFC)Q??[},G[τṟ|/V^@  P@udLgW#KfPIyF'U^כD-(3UFw^zT@@6IM##)9T* >{Z P FެH5D 5mהkgzJ ۭզ:6tͺ11EEƭT&R@@䇔t$iR[F .PC_5Ej紎vb"PS#oZUrN@@ T&g̚.!915Lm)~Tgkl<([_ƮT@".q P5FP}rZ$g@M6~2Y"au@ P#+:dUYWu3Mr1ӄw>O,]yv* P@@J@m\kI~*gxs2 T* PeNaDMJDbԻ77o%v;} o PNo@EEM_AExX!P{,GuxIHfy$0R0Qrlr\±l((jo],)'^'RTmxTlUk==szNyܹU}La.]TTPQiCl+P_Cl-=UF|Ym$Y>- aFnSN:nJ>bljz a_P@PiF!~Q x{Xmx{>f.$n61 #9e*fšGf߾R[ q۔?'1e%F@P_Y%VPg-^^*:Wg9J?!i7.~۶ 竭ۄ}4m61 ^@̓ -3ٞL2:vl32} A,M&Λo}?E/oG) `@m Y(;jU݋dԹc߬i]PP@@`&jyӸ2s |f6jfꝇ?U<åO/Uv}=H9rhu=|zp]j|m}đU:3u g1>TTPA@XˀOOEz^<#PGˮZ[|* HjjT(Զmf!VEPѷK}k_;Yw]xV66Xʅx{Y^7(~@@Ņnpnn.~tP}XZmW/Xܮc<ũx,(~x}sP<ŋ/ LA@E@PPYZ~4^~I?ۏ5poW: `9RMOU?'5QPLM~N7OX@PTTf6~?&2 ~@n3'uGHO5Mct4jFSӘj ?*ƠPyYmj?Tq3*X-g>ujih@PPTT` SPZ^?uj(N=^@FZԸs **kPW EMOzdj>(UӥŤiJ-5:- U[Dy 5@@]'iU@@@PTg`j{l:}2x Ͽ_k(֧N2ն655)Au=r*ʧMnPôle|nPPA@P6hA-jﺈ!,&NHi0)\ k t1jd:5 [G@E@@@:XEXLB1U@P˅ciajmb,F.ҽvۢ/<XE@PA@PIj2w26=57fUMDlr2&***PpmSG s(Q5]OPPWoSqKSl_>zu) ,"RŢFԆiS6z4YDԨG-"<mG@eOKIb癿+:ݱLPᄎy]q͙IsW?T`*,ԙ9y OYyo&wb^w(q(^Ŗ⟃,>íŹ'[OZ}Wq[Gv7^An^<v}PM@PTP?v=5jkKr !g #JaUș̖aA4 qvkq%j4+<&q2PBSs; dT@@E@Z5l5:4jtҀ::q s/o@Q5ZP>4P6k2H@PTTߡ>+62xVm#PNf>LBi߮H5N !js0OoV^g5jۈzh4"p<^]K6Mʣ`~/\* "25[jhQ H=7CIkRUA._&q.?yx|p;uxԡEiniڴ լ]***f*+mWqQkn@* 7# Jbm]j}*iTw@@ PTPT P?~۽01U@@@PA@@@TPPTT@@@@E@@@/~XT`U*7* *oc|;>quP#_O<=q?GǾ  lxc|C@@@i    ," * ,n9xgqm 0-TTOTTYD TPP@@`U    >/ 9H Zxc/|#wǾK7 0?bU▃G>ݲ^_GT@@@@Pg W 5n&@@PK,n9xTTT e`JW7;[' XyJ?/>[ɶi_~;c|8, o_nxΡ<,o9&C0} {1/c,W?c+.=?ɏuVm[ދprް >FTE~@E+FWFR0XmPͨqx#O'x?V#>Bi$a~ض:W ihnӈ{̩gjmoysZG7֫0}#P`^j>"2I)v=WrۨeW\1F؜ LYS ׳_AX@/CD@myް 05R8%teӴaPC1gǔb⶿+#a9c :"Dga/̎7|~>ϽT>Rj% G#踣;/  b;ӵpQ} e & S#׮q_:ݿ_/q)E^N^g;_Ԃe~-dkPʞX' o%۶O[>7~_yC@@@P,">Fx&A -# ;+\IpT@M#^ppc9G.hW0~{0}Ms?Xs89WLi|~{>"**:QiH|_B`W@moi5 AW#>Bi$a~ض Y΃fk4fN=?}hTk}Cϓ-^! b@ѽ:!̶<<7palj̍#7tt->F]X@-N^omkyC@@@XŀCŁof ̖SkSѳpH8 ˎuy<as/6S;./P;1h.5faz\ޠ#h[x:oP1`F ꬨ-"FSFA6ױPRmtt85r?"R O|>kkr>ǽhk-|_9R7O *J˯ˀk ʪuMTPU@H!" *OϦy@oz * "**[.;yPPT@@"R $j ŭȦݟl*NL.*}qsq'"&lY(>zb׮=7,î`PP6v@=R<_)_tmu_N\}'L,\ ܑU<>χ{;9ΜPYtrM?s L;w]' l)L0U@=Yyso;%A 4GPC]x*ZDjlݺط`-ŽƲص'ľko)j 058"oxjo=V3n.^.wiqmC#P.' 'N]T>z|/6짺o2HXzLԋyx{PW@eNj;E?Wm]\߻07]^l۾'Kcs35Kq^@]:ʑ1!jM ahXF8D|)F??+ß| îJ".۳ƹ X9v3.tsa\vGѨQ@@@߀SuO_:>'Д5 Ɉʘ&2ϋHn(˯oamrG۶m Z:joaW! M)h0>Z@e~5n*?;w]ZX!{gvPPu 9P6yQq:Y!]gk~64\k4 3P5^@e^jX,*|زZX![_&㠀 j69\.ӻNf@mM,/ @+ChR#jFXHS9?k+*'Jjte2#\ZX!^ZM@@@̇T}j효U@]Ӏz*nȖ =jK~u}l.76,!^ *3{ソXX+\ZDjj}4`^5j/W P!N bXYJG/F,{~ds4PxuzեXk`c"l6'N]T>){| ]O{5n-G Wѥ԰1>;** ~@?|? ⥀\u|cw.4FG%F$Ζ>z%2##P߭bͣCÂU 恵eqb"35>Rt| 9͋Ϫ kP\sS-FO"Raigxx^2QzlE/*N_7P;?[HMF N P5nݺ{a`a`mvW?<.ޞ RѴM?0f@sʼn?QN<_?[O?5W-9?_(\tT@&tLfDfMPg)v~XD?fPPT`u]ו<C0}~CYo=P痰T1$FզZ1+[MJջ.8(QC#H!}M3q NU,fS|jj65^#uj\>RjX*F=. oٸuVy] uTs`Txr;tpc@O=EQs5'P]]@]Ai5w޻>z >g`衇6Q@/+ ,#G>_gx/~sP5.~5Ѣ(S"ۏ~?*O}?0kCǰKO=̪Ԧs೎ 0Gu߾e6M@uc@u5Lٜ¿jc}cM4g~@@eF T@ T8?LSm|L~#2ß0ZWZ{|=6MN7=/=>±+{ϦN_8)܍:gmd; oɏ=Kxl>Ryc]38 8=]s0souN{n m5{u=_A@PPPC %ĒnbHA&Ө7l+lcL?^J DsϦyc H=kOb0x 7ήn ],]Y~IR 񽊯#/"j8xz)c!0>ϸbh_TKZ_~?~b}yǞ׶P9@SGqjsAx* ԻT>]ſ[7SC<%>&x| k>']'DiCNϷiZ|-\ws4ɹ'=׸߃4" ,)ijWm<6Et8%O|4t?MqtbT8j/zuܓ1٨1/]ӯ'$oS@Mi=Rۦ#Qz]4}b|w}>Gq޿{z@@TT`IP?bLPc` (N7KSzҦiqr59G羦cKGc|LWqOzΚs6N@>-W@Xc@ SBT uT@OQ3uЮmFqG}>Gq߿{`,@@TT`G~{ZW#Pk=q50MG6MiM_~ܓ)L^)]7ɱ@ ^Ҵij{;>Cmw6q1ըThsGZs=PTPz֝@ռjTg5oDjG܅¶q]9+F31^Nm9Oe95s?cAxw=W@@PղťSБe>=?_tmu_Epj˞WK..Y;TT`߾pD@Tuʀ:Ҽ0WByospuӣ+OTTPTPA@Xg5Ŧ<46W7v{՗lM^(6=m3ʳ);Lg[u:xJAA7ZPP`rgLuj=t6R) ]Sːj ?*  es HOB8S)딎P[.#0Q@'yQq:Y!]gu^jl  /攄4d&5=;M}<5[jdBm:5PF& B Ce#PڴԈJ>+QTTPTT\Xh>JvMTf  tDjSP>jݹ~ 8W\8{rNg.+/s?Nq쟛ܷxZ/ * * ** POgxx;Ņ $a;4~4LP9,vxd6athx{^(L7>_^gPCuUK*6[w?ş(|`o#}l+n=f9.F2.n縬yvBu`TTPو5 !&Bqsghb`Y8RA 'MB`c&:emfylT˘;A0MDžxd^ miSm-]mT@@T6H@GaGBQh,N)!y6 FۧŸn3O!fQU;4][9C=" ZhҦH8~H=Y<sO,>Om$Z^j0{2*r^Pe@]< Ctyi *PSS{m޸Rre =R t֦ٿ:\"oxd|U ]td@?m k@m|U@@@PTuڠQzdq7{}j4/ߦ{vlEyiS|KqzN œ@oڲTPTT P;VWaX өGՐ#~F nkY]yhEy +O:{q95P`ԱVW2םKFl񳚮ѱr}=iWWupr>@@E@C 4+61#BќYS?k ٱ8H?ڻyS *3f˖UԹl o/gq`jF `ylZtgN.px4S@E@e=-Wu۞{oA`j:6MhtNx\q湓N򷬮F꤯S@E@e=sMgpמ|'a\cٮ**<\qutq/$0>VWY,Tf])_zm5k3nYev]wl**|]PPԿ>wW.T4}?R@@@PWV6y$, fg{wK*^ߩn{) d@T6+YwK|zcQO=fMŖ- ** ̃;?T @¨%. ʌ/.csخ+v츶ضmϒ** *    ^ꦏ\ `*0TTTPPPf'nX/TTU *** ****   *** *    **,̧iIENDB`jeremyevans-rodauth-b53f402/www/public/images/rodauth.svg000066400000000000000000000004461515725514200236060ustar00rootroot00000000000000 Rodauth