diff --git a/src/invidious.cr b/src/invidious.cr index cdf64696..2ddd3d0d 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -315,517 +315,12 @@ Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch Invidious::Routing.get "/results", Invidious::Routes::Search, :results Invidious::Routing.get "/search", Invidious::Routes::Search, :search +Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page +Invidious::Routing.post "/login", Invidious::Routes::Login, :login +Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout # Users -get "/login" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - if user - next env.redirect "/feed/subscriptions" - end - - if !config.login_enabled - next 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 - -post "/login" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - referer = get_referer(env, "/feed/subscriptions") - - if !config.login_enabled - next 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, "US", 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 - next 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: ""} - - next templated "login" - end - - if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" - next 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" - next 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 - next 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 - next 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") - next 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_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.full_path, headers) - - headers = login.cookies.add_request_headers(headers) - location = login.headers["Location"]?.try { |u| URI.parse(u) } - end - - cookies = HTTP::Cookies.from_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}
) - next error_template(500, error_message) - end - when "invidious" - if !email - next error_template(401, "User ID is a required field") - end - - if !password - next 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 - next 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 - next 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 - next error_template(400, "Registration has been disabled by administrator.") - end - - if password.empty? - next error_template(401, "Password cannot be empty") - end - - # See https://security.stackexchange.com/a/39851 - if password.bytesize > 55 - next 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 - - next 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 - next error_template(400, ex) - end - else # "text" - answer = Digest::MD5.hexdigest(answer.downcase.strip) - - if tokens.empty? - next 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 - next error_template(500, error_exception) - end - end - end - - sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - user, sid = create_user(sid, email, password) - 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 - -post "/signout" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next 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 - next 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 - get "/preferences" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr new file mode 100644 index 00000000..45a6d4d8 --- /dev/null +++ b/src/invidious/routes/login.cr @@ -0,0 +1,508 @@ +class Invidious::Routes::Login < Invidious::Routes::BaseRoute + def 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 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, "US", 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_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.full_path, headers) + + headers = login.cookies.add_request_headers(headers) + location = login.headers["Location"]?.try { |u| URI.parse(u) } + end + + cookies = HTTP::Cookies.from_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) + 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 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