Merge pull request #1560 from matthewmcgarvey/extract-login-routes
Extract login/signout routes from global file
This commit is contained in:
commit
3fd885a188
2 changed files with 511 additions and 508 deletions
511
src/invidious.cr
511
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}.<br/>"
|
||||
|
||||
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}.<br/>"
|
||||
|
||||
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}.<br/>"
|
||||
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.<br/>"
|
||||
else
|
||||
traceback << "Using challenge #{challenge_results[0][-1][0][0][8]}.<br/>"
|
||||
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.<br/>"
|
||||
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.<br/>"
|
||||
|
||||
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}<br/>Traceback:<br/><div style="padding-left:2em" id="traceback">#{traceback.gets_to_end}</div>)
|
||||
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]?
|
||||
|
||||
|
|
508
src/invidious/routes/login.cr
Normal file
508
src/invidious/routes/login.cr
Normal file
|
@ -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}.<br/>"
|
||||
|
||||
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}.<br/>"
|
||||
|
||||
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}.<br/>"
|
||||
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.<br/>"
|
||||
else
|
||||
traceback << "Using challenge #{challenge_results[0][-1][0][0][8]}.<br/>"
|
||||
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.<br/>"
|
||||
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.<br/>"
|
||||
|
||||
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}<br/>Traceback:<br/><div style="padding-left:2em" id="traceback">#{traceback.gets_to_end}</div>)
|
||||
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
|
Loading…
Reference in a new issue