diff --git a/src/invidious.cr b/src/invidious.cr
index cdf646962..2ddd3d0d4 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 000000000..45a6d4d8c
--- /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