diff --git a/config/sql/users.sql b/config/sql/users.sql index ad002ec2..2da3becf 100644 --- a/config/sql/users.sql +++ b/config/sql/users.sql @@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS public.users token text, watched text[], feed_needs_update boolean, + totp_secret VARCHAR(128) CONSTRAINT users_email_key UNIQUE (email) ); diff --git a/locales/en-US.json b/locales/en-US.json index 74f43d90..8dae632f 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -484,5 +484,16 @@ "channel_tab_releases_label": "Releases", "channel_tab_playlists_label": "Playlists", "channel_tab_community_label": "Community", - "channel_tab_channels_label": "Channels" -} + "channel_tab_channels_label": "Channels", + "setup_totp_form_header": "Setup two-factor authentication (TOTP)", + "setup_totp_instructions_download_auth": "Install an authenticator app (or anything that supports TOTP) on your device", + "setup_totp_instructions_enter_code": "Enter the following secret code:", + "setup_totp_instructions_validate_code": "Enter the 6 digit number on your screen. Be sure to do it under thirty seconds!", + "setup_totp_submit_button": "Setup TOTP", + "general_totp_empty_field": "The TOTP code is a required field", + "general_totp_invalid_code": "The TOTP code entered is invalid", + "general_totp_enter_code_field": "6 digit number", + "general_totp_enter_code_header": "Two-factor authentication", + "general_totp_verify_button": "Verify", + "remove_totp_header": "Remove two-factor authentication (TOTP)", + "remove_totp_confirm_message": "Are you sure you would like to remove two-factor-authentication?"} diff --git a/shard.lock b/shard.lock index 55fcfe46..e6f24c2c 100644 --- a/shard.lock +++ b/shard.lock @@ -1,12 +1,24 @@ version: 2.0 shards: + ameba: + git: https://github.com/crystal-ameba/ameba.git + version: 0.14.4 + athena-negotiation: git: https://github.com/athena-framework/negotiation.git - version: 0.1.1 + version: 0.1.3 backtracer: git: https://github.com/sija/backtracer.cr.git - version: 1.2.1 + version: 1.2.2 + + base32: + git: https://github.com/philnash/base32.git + version: 0.1.1+git.commit.0a21c1d90731fdefcb3f0db4913f49d3d25350ac + + crotp: + git: https://github.com/philnash/crotp.git + version: 1.0.0 db: git: https://github.com/crystal-lang/crystal-db.git @@ -38,12 +50,9 @@ shards: spectator: git: https://github.com/icy-arctic-fox/spectator.git - version: 0.10.4 + version: 0.10.6 sqlite3: git: https://github.com/crystal-lang/crystal-sqlite3.git version: 0.18.0 - ameba: - git: https://github.com/crystal-ameba/ameba.git - version: 0.14.3 diff --git a/shard.yml b/shard.yml index e929160d..391996fa 100644 --- a/shard.yml +++ b/shard.yml @@ -28,6 +28,9 @@ dependencies: athena-negotiation: github: athena-framework/negotiation version: ~> 0.1.1 + crotp: + github: philnash/crotp + version: ~> 1.0.0 development_dependencies: spectator: diff --git a/src/invidious/database/migrations/0011_add_totp_secret_to_users_table.cr b/src/invidious/database/migrations/0011_add_totp_secret_to_users_table.cr new file mode 100644 index 00000000..2501123d --- /dev/null +++ b/src/invidious/database/migrations/0011_add_totp_secret_to_users_table.cr @@ -0,0 +1,11 @@ +module Invidious::Database::Migrations + class AddTotpSecretToUsersTable < Migration + version 11 + + def up(conn : DB::Connection) + conn.exec <<-SQL + ALTER TABLE users ADD COLUMN totp_secret VARCHAR(128) + SQL + end + end +end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index a006d602..1f0bc4b0 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -445,3 +445,14 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String) end return text end + +# Templates the 2fa validator page. +# +# Requires the env, user, sid and locale variables for +# generating a csrf_token and the required variables for the view. +def call_totp_validator(env, user, sid, locale) + referer = URI.decode_www_form(env.get?("current_page").to_s) + csrf_token = generate_response(sid, {":2fa/validate"}, HMAC_KEY) + email, password = {user.email, nil} + return templated "user/validate_2fa" +end diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index 9d930841..6091d71c 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -1,5 +1,7 @@ {% skip_file if flag?(:api_only) %} +require "crotp" + module Invidious::Routes::Account extend self @@ -21,6 +23,11 @@ module Invidious::Routes::Account user = user.as(User) sid = sid.as(String) + + if user.totp_secret && env.request.cookies["2faVerified"]?.try &.value != "1" || nil + return call_totp_validator(env, user, sid, locale) + end + csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY) templated "user/change_password" @@ -96,6 +103,11 @@ module Invidious::Routes::Account user = user.as(User) sid = sid.as(String) + + if user.totp_secret && env.request.cookies["2faVerified"]?.try &.value != "1" || nil + return call_totp_validator(env, user, sid, locale) + end + csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY) templated "user/delete_account" @@ -195,14 +207,20 @@ module Invidious::Routes::Account user = env.get? "user" sid = env.get? "sid" + + user = user.as(User) + sid = sid.as(String) + + if user.totp_secret && env.request.cookies["2faVerified"]?.try &.value != "1" || nil + return call_totp_validator(env, user, sid, locale) + end + referer = get_referer(env) if !user return env.redirect "/login?referer=#{URI.encode_path_segment(env.request.resource)}" end - user = user.as(User) - sid = sid.as(String) csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY) scopes = env.params.query["scopes"]?.try &.split(",") @@ -351,4 +369,195 @@ module Invidious::Routes::Account return "{}" end end + + # ------------------- + # 2fa through OTP handling + # ------------------- + + # Templates the page to setup 2fa on an user account + def setup_2fa_page(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, unroll: false) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":2fa/setup"}, HMAC_KEY) + + db_secret = Random::Secure.random_bytes(16).hexstring + totp = CrOTP::TOTP.new(db_secret) + user_secret = totp.base32_secret + + return templated "user/setup_2fa" + end + + # Handles requests to setup 2fa on an user account + def setup_2fa(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, unroll: false) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + return error_template(400, ex) + end + + totp_code = env.params.body["totp_code"]? + db_secret = env.params.body["db_secret"] # Must exist + if !totp_code + return error_template(401, translate(locale, "general-totp-empty-field")) + end + + totp_instance = CrOTP::TOTP.new(db_secret) + if !totp_instance.verify(totp_code) + return error_template(401, translate(locale, "general-totp-invalid-code")) + end + + PG_DB.exec("UPDATE users SET totp_secret = $1 WHERE email = $2", db_secret.to_s, user.email) + env.redirect referer + end + + # Handles requests to validate a TOTP code on an user account + def validate_2fa(env) + locale = env.get("preferences").as(Preferences).locale + referer = get_referer(env, unroll: false) + + email = env.params.body["email"]?.try &.downcase.byte_slice(0, 254) + password = env.params.body["password"]? + totp_code = env.params.body["totp_code"]? + # This endpoint is only called when the user has a totp_secret. + user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User).not_nil! + + if !totp_code + return error_template(401, translate(locale, "general-totp-empty-field")) + end + + totp_instance = CrOTP::TOTP.new(user.totp_secret.not_nil!) + if !totp_instance.verify(totp_code) + return error_template(401, translate(locale, "general-totp-invalid-code")) + end + + if Kemal.config.ssl || CONFIG.https_only + secure = true + else + secure = false + end + + # + # The validate_2fa method is used in two cases: + # 1. To authenticate the user when logging in + # 2. To verify that the user wishes to proceed with a dangerous action. + # + # As we've verified that the totp given is correct we can now proceed with + # authenticating and/or redirecting the user back to where they came from + # + + logging_in = (email && password) + + if logging_in + # Authenticate the user. The rest follows the code in login.cr + if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.not_nil!.byte_slice(0, 55)) + # + sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) + PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) + + if CONFIG.domain + env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years, + secure: secure, http_only: true, path: "/") + else + env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years, + secure: secure, http_only: true, path: "/") + end + else + return error_template(401, "Wrong username or password") + end + + # Since this user has already registered, we don't want to overwrite their preferences + if env.request.cookies["PREFS"]? + cookie = env.request.cookies["PREFS"] + cookie.expires = Time.utc(1990, 1, 1) + env.response.cookies << cookie + end + + env.redirect referer + else + token = env.params.body["csrf_token"] + + begin + validate_request(token, env.get?("sid").as(String), env.request, HMAC_KEY, locale) + rescue ex + return error_template(400, ex) + end + + if CONFIG.domain + env.response.cookies["2faVerified"] = HTTP::Cookie.new(name: "2faVerified", domain: "#{CONFIG.domain}", value: "1", expires: Time.utc + 5.minutes, secure: secure, http_only: true, path: "/") + else + env.response.cookies["2faVerified"] = HTTP::Cookie.new(name: "2faVerified", value: "1", expires: Time.utc + 5.minutes, secure: secure, http_only: true, path: "/") + end + end + + env.redirect referer + end + + # Templates the page to remove 2fa on an user account + def remove_2fa_page(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, unroll: false) + + if !user || user.is_a? User && !user.totp_secret + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":2fa/remove"}, HMAC_KEY) + + return templated "user/remove_2fa" + end + + # Handles requests to remove 2fa on an user account + def remove_2fa(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, unroll: false) + + if !user || user.is_a? User && !user.totp_secret + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + return error_template(400, ex) + end + + PG_DB.exec("UPDATE users SET totp_secret = $1 WHERE email = $2", nil, user.email) + env.redirect referer + end end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index d0f7ac22..fdc0ead8 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -57,6 +57,12 @@ module Invidious::Routes::Login if user if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) + # If the password is correct then we'll go ahead and begin 2fa if applicable + if user.totp_secret + csrf_token = nil # setting this to nil for compatibility reasons. + return templated "user/validate_2fa" + end + sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) Invidious::Database::SessionIDs.insert(sid, email) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 9c43171c..01d1d79a 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -78,6 +78,13 @@ module Invidious::Routing post "/token_ajax", Routes::Account, :token_ajax post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription get "/subscription_manager", Routes::Subscriptions, :subscription_manager + + # 2fa routes + Invidious::Routing.get "/2fa/setup", Routes::Account, :setup_2fa_page + Invidious::Routing.post "/2fa/setup", Routes::Account, :setup_2fa + Invidious::Routing.get "/2fa/remove", Routes::Account, :remove_2fa_page + Invidious::Routing.post "/2fa/remove", Routes::Account, :remove_2fa + Invidious::Routing.post "/2fa/validate", Routes::Account, :validate_2fa end def register_iv_playlist_routes diff --git a/src/invidious/user/user.cr b/src/invidious/user/user.cr index a6d05fd1..bda72b88 100644 --- a/src/invidious/user/user.cr +++ b/src/invidious/user/user.cr @@ -7,6 +7,7 @@ struct Invidious::User property notifications : Array(String) property subscriptions : Array(String) property email : String + property totp_secret : String? @[DB::Field(converter: Invidious::User::PreferencesConverter)] property preferences : Preferences diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 65566d20..f5488509 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -17,6 +17,7 @@ def create_user(sid, email, password) token: token, watched: [] of String, feed_needs_update: true, + totp_secret: nil, }) return user, sid diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index dfda1434..8c6cc7f3 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -345,6 +345,14 @@ <%= translate(locale, "Watch history") %> +