{% skip_file if flag?(:api_only) %} module Invidious::Routes::Login def self.login_page(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? user = env.get? "user" return env.redirect "/feed/subscriptions" if user if !CONFIG.login_enabled return error_template(400, "Login has been disabled by administrator.") end referer = get_referer(env, "/feed/subscriptions") email = nil password = nil captcha = nil account_type = env.params.query["type"]? account_type ||= "invidious" captcha_type = env.params.query["captcha"]? captcha_type ||= "image" tfa = env.params.query["tfa"]? prompt = nil templated "login" end def self.login(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? referer = get_referer(env, "/feed/subscriptions") if !CONFIG.login_enabled return error_template(403, "Login has been disabled by administrator.") end # https://stackoverflow.com/a/574698 email = env.params.body["email"]?.try &.downcase.byte_slice(0, 254) password = env.params.body["password"]? account_type = env.params.query["type"]? account_type ||= "invidious" case account_type when "google" tfa_code = env.params.body["tfa"]?.try &.lchop("G-") traceback = IO::Memory.new # See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82 begin client = QUIC::Client.new(LOGIN_URL) headers = HTTP::Headers.new login_page = client.get("/ServiceLogin") headers = login_page.cookies.add_request_headers(headers) lookup_req = { email, nil, [] of String, nil, "JP", nil, nil, 2, false, true, {nil, nil, {2, 1, nil, 1, "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", nil, [] of String, 4}, 1, {nil, nil, [] of String}, nil, nil, nil, true, }, email, }.to_json traceback << "Getting lookup..." headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" headers["Google-Accounts-XSRF"] = "1" response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req)) lookup_results = JSON.parse(response.body[5..-1]) traceback << "done, returned #{response.status_code}.
" user_hash = lookup_results[0][2] if token = env.params.body["token"]? answer = env.params.body["answer"]? captcha = {token, answer} else captcha = nil end challenge_req = { user_hash, nil, 1, nil, {1, nil, nil, nil, {password, captcha, true}, }, {nil, nil, {2, 1, nil, 1, "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", nil, [] of String, 4}, 1, {nil, nil, [] of String}, nil, nil, nil, true, }, }.to_json traceback << "Getting challenge..." response = client.post("/_/signin/sl/challenge", headers, login_req(challenge_req)) headers = response.cookies.add_request_headers(headers) challenge_results = JSON.parse(response.body[5..-1]) traceback << "done, returned #{response.status_code}.
" headers["Cookie"] = URI.decode_www_form(headers["Cookie"]) if challenge_results[0][3]?.try &.== 7 return error_template(423, "Account has temporarily been disabled") end if token = challenge_results[0][-1]?.try &.[-1]?.try &.as_h?.try &.["5001"]?.try &.[-1].as_a?.try &.[-1].as_s account_type = "google" captcha_type = "image" prompt = nil tfa = tfa_code captcha = {tokens: [token], question: ""} return templated "login" end if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" return error_template(401, "Incorrect password") end prompt_type = challenge_results[0][-1]?.try &.[0].as_a?.try &.[0][2]? if {"TWO_STEP_VERIFICATION", "LOGIN_CHALLENGE"}.includes? prompt_type traceback << "Handling prompt #{prompt_type}.
" case prompt_type when "TWO_STEP_VERIFICATION" prompt_type = 2 else # "LOGIN_CHALLENGE" prompt_type = 4 end # Prefer Authenticator app and SMS over unsupported protocols if !{6, 9, 12, 15}.includes?(challenge_results[0][-1][0][0][8].as_i) && prompt_type == 2 tfa = challenge_results[0][-1][0].as_a.select { |auth_type| {6, 9, 12, 15}.includes? auth_type[8] }[0] traceback << "Selecting challenge #{tfa[8]}..." select_challenge = {prompt_type, nil, nil, nil, {tfa[8]}}.to_json tl = challenge_results[1][2] tfa = client.post("/_/signin/selectchallenge?TL=#{tl}", headers, login_req(select_challenge)).body tfa = tfa[5..-1] tfa = JSON.parse(tfa)[0][-1] traceback << "done.
" else traceback << "Using challenge #{challenge_results[0][-1][0][0][8]}.
" tfa = challenge_results[0][-1][0][0] end if tfa[5] == "QUOTA_EXCEEDED" return error_template(423, "Quota exceeded, try again in a few hours") end if !tfa_code account_type = "google" captcha_type = "image" case tfa[8] when 6, 9 prompt = "Google verification code" when 12 prompt = "Login verification, recovery email: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" when 15 prompt = "Login verification, security question: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" else prompt = "Google verification code" end tfa = nil captcha = nil return templated "login" end tl = challenge_results[1][2] request_type = tfa[8] case request_type when 6 # Authenticator app tfa_req = { user_hash, nil, 2, nil, {6, nil, nil, nil, nil, {tfa_code, false}, }, }.to_json when 9 # Voice or text message tfa_req = { user_hash, nil, 2, nil, {9, nil, nil, nil, nil, nil, nil, nil, {nil, tfa_code, false, 2}, }, }.to_json when 12 # Recovery email tfa_req = { user_hash, nil, 4, nil, {12, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, {tfa_code}, }, }.to_json when 15 # Security question tfa_req = { user_hash, nil, 5, nil, {15, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, {tfa_code}, }, }.to_json else return error_template(500, "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.") end traceback << "Submitting challenge..." response = client.post("/_/signin/challenge?hl=en&TL=#{tl}", headers, login_req(tfa_req)) headers = response.cookies.add_request_headers(headers) challenge_results = JSON.parse(response.body[5..-1]) if (challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED") || (challenge_results[0][-1]?.try &.[5] == "INVALID_INPUT") return error_template(401, "Invalid TFA code") end traceback << "done.
" end traceback << "Logging in..." location = URI.parse(challenge_results[0][-1][2].to_s) cookies = HTTP::Cookies.from_client_headers(headers) headers.delete("Content-Type") headers.delete("Google-Accounts-XSRF") loop do if !location || location.path == "/ManageAccount" break end # Occasionally there will be a second page after login confirming # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle. if location.path.starts_with? "/b/0/SmsAuthInterstitial" traceback << "Unhandled dialog /b/0/SmsAuthInterstitial." end login = client.get(location.request_target, headers) headers = login.cookies.add_request_headers(headers) location = login.headers["Location"]?.try { |u| URI.parse(u) } end cookies = HTTP::Cookies.from_client_headers(headers) sid = cookies["SID"]?.try &.value if !sid raise "Couldn't get SID." end user, sid = get_user(sid, headers, PG_DB) # We are now logged in traceback << "done.
" host = URI.parse(env.request.headers["Host"]).host if Kemal.config.ssl || CONFIG.https_only secure = true else secure = false end cookies.each do |cookie| if Kemal.config.ssl || CONFIG.https_only cookie.secure = secure else cookie.secure = secure end if cookie.extension cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host) cookie.extension = cookie.extension.not_nil!.gsub("Secure; ", "") end env.response.cookies << cookie end if env.request.cookies["PREFS"]? preferences = env.get("preferences").as(Preferences) PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) cookie = env.request.cookies["PREFS"] cookie.expires = Time.utc(1990, 1, 1) env.response.cookies << cookie end env.redirect referer rescue ex traceback.rewind # error_message = translate(locale, "Login failed. This may be because two-factor authentication is not turned on for your account.") error_message = %(#{ex.message}
Traceback:
#{traceback.gets_to_end}
) return error_template(500, error_message) end when "invidious" if !email return error_template(401, "User ID is a required field") end if !password return error_template(401, "Password is a required field") end user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User) if user if !user.password return error_template(400, "Please sign in using 'Log in with Google'") 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)) PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) if Kemal.config.ssl || CONFIG.https_only secure = true else secure = false end 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 else if !CONFIG.registration_enabled return error_template(400, "Registration has been disabled by administrator.") end if password.empty? return error_template(401, "Password cannot be empty") end # See https://security.stackexchange.com/a/39851 if password.bytesize > 55 return error_template(400, "Password cannot be longer than 55 characters") end password = password.byte_slice(0, 55) if CONFIG.captcha_enabled captcha_type = env.params.body["captcha_type"]? answer = env.params.body["answer"]? change_type = env.params.body["change_type"]? if !captcha_type || change_type if change_type captcha_type = change_type end captcha_type ||= "image" account_type = "invidious" tfa = false prompt = "" if captcha_type == "image" captcha = generate_captcha(HMAC_KEY, PG_DB) else captcha = generate_text_captcha(HMAC_KEY, PG_DB) end return templated "login" end tokens = env.params.body.select { |k, v| k.match(/^token\[\d+\]$/) }.map { |k, v| v } answer ||= "" captcha_type ||= "image" case captcha_type when "image" answer = answer.lstrip('0') answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) begin validate_request(tokens[0], answer, env.request, HMAC_KEY, PG_DB, locale) rescue ex return error_template(400, ex) end else # "text" answer = Digest::MD5.hexdigest(answer.downcase.strip) if tokens.empty? return error_template(500, "Erroneous CAPTCHA") end found_valid_captcha = false error_exception = Exception.new tokens.each_with_index do |token, i| begin validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale) found_valid_captcha = true rescue ex error_exception = ex end end if !found_valid_captcha return error_template(500, error_exception) end end end sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) user, sid = create_user(sid, email, password) if language_header = env.request.headers["Accept-Language"]? if language = ANG.language_negotiator.best(language_header, LOCALES.keys) user.preferences.locale = language.header end end user_array = user.to_a user_array[4] = user_array[4].to_json # User preferences args = arg_array(user_array) PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array) PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) view_name = "subscriptions_#{sha256(user.email)}" PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") if Kemal.config.ssl || CONFIG.https_only secure = true else secure = false end 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 if env.request.cookies["PREFS"]? preferences = env.get("preferences").as(Preferences) PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) cookie = env.request.cookies["PREFS"] cookie.expires = Time.utc(1990, 1, 1) env.response.cookies << cookie end end env.redirect referer else env.redirect referer end end def self.signout(env) locale = LOCALES[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, PG_DB, locale) rescue ex return error_template(400, ex) end PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", sid) env.request.cookies.each do |cookie| cookie.expires = Time.utc(1990, 1, 1) env.response.cookies << cookie end env.redirect referer end end