diff --git a/locales/en-US.json b/locales/en-US.json index 74f43d90..664d99a9 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -484,5 +484,15 @@ "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 authenticiation (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": "Verifiy" } diff --git a/shard.lock b/shard.lock index 235e4c25..03f89fbb 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 @@ -42,12 +54,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 7ee0bb2a..301f6c3f 100644 --- a/shard.yml +++ b/shard.yml @@ -31,6 +31,8 @@ dependencies: athena-negotiation: github: athena-framework/negotiation version: ~> 0.1.1 + crotp: + github: philnash/crotp development_dependencies: spectator: diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index a006d602..e54aa0a7 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -445,3 +445,78 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String) end return text end + +def totp_validator(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + referer = get_referer(env) + + email = env.params.body["email"]?.try &.downcase.byte_slice(0, 254) + password = env.params.body["password"]? + totp_code = env.params.body["totp_code"]? + user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User) + + if !totp_code + return error_template(401, translate(locale, "general-totp-empty-field")) + end + + # Verify if possible + if token = env.params.body["csrf_token"]? + begin + validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + rescue ex + return error_template(400, ex) + end + end + + totp_instance = CrOTP::TOTP.new(user.totp_secret) + 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 + + # There are two routes we can go here. + # 1. Where the user is already logged in and is + # confirming an dangerous task. + # 2. The user is logging in. + # + # This can be detected by the hidden email and password parameter + + # https://stackoverflow.com/a/574698 + if email && password + # The rest of the login code. + if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.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) + else + env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years, + secure: secure, http_only: true) + 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 + if CONFIG.domain + env.response.cookies["2faVerified"] = HTTP::Cookie.new(name: "2faVerified", domain: "#{CONFIG.domain}", value: true, expires: Time.utc + 1.hours, secure: secure, http_only: true) + else + env.response.cookies["2faVerified"] = HTTP::Cookie.new(name: "2faVerified", value: true, expires: Time.utc + 1.hours, secure: secure, http_only: true) + end + end +end diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index 9d930841..f3241057 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 @@ -351,4 +353,137 @@ module Invidious::Routes::Account return "{}" end end + + # ------------------- + # 2fa through OTP handling + # ------------------- + def setup_2fa_page(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":setup_2fa"}, 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 + + # Setup TOTP (post) request. + def setup_2fa(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + 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) + end + + # Validate 2fa code endpoint + def validate_2fa(env) + locale = env.get("preferences").as(Preferences).locale + referer = get_referer(env) + + 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 + + # There are two routes we can go here. + # 1. Where the user is already logged in and is + # confirming an dangerous task. + # 2. The user is logging in. + # + # This can be detected by the hidden email and password parameter + + # https://stackoverflow.com/a/574698 + if email && password + # The rest of the login code. + if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.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) + else + env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years, + secure: secure, http_only: true) + 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 + 1.hours, secure: secure, http_only: true) + else + env.response.cookies["2faVerified"] = HTTP::Cookie.new(name: "2faVerified", value: "1", expires: Time.utc + 1.hours, secure: secure, http_only: true) + end + end + end end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index d0f7ac22..44aea163 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -56,6 +56,12 @@ module Invidious::Routes::Login user = Invidious::Database::Users.select(email: email) if user + # If user has setup TOTP + if user.totp_secret + csrf_token = nil # setting this to false for compatibility reasons. + return templated "user/validate_2fa" + end + if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) 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..fc21f976 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -78,6 +78,11 @@ 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 "/setup_2fa", Routes::Account, :setup_2fa_page + Invidious::Routing.post "/setup_2fa", Routes::Account, :setup_2fa + Invidious::Routing.post "/validate_2fa", Routes::Account, :validate_2fa end def register_iv_playlist_routes diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index dfda1434..a0f77dbf 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -345,6 +345,10 @@ <%= translate(locale, "Watch history") %> +
+ diff --git a/src/invidious/views/user/setup_2fa.ecr b/src/invidious/views/user/setup_2fa.ecr new file mode 100644 index 00000000..a22e5f0b --- /dev/null +++ b/src/invidious/views/user/setup_2fa.ecr @@ -0,0 +1,36 @@ +<% content_for "header" do %> +