summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorOmar Roth <omarroth@hotmail.com>2019-04-18 16:23:50 -0500
committerOmar Roth <omarroth@hotmail.com>2019-04-18 16:23:50 -0500
commit2a6c81a89dd64c00dff0b37af1c6637771aad27e (patch)
tree0eb11730faeb40775bc1adba462e86dc1655bc94 /src
parent301871aec68f6cf775b77da86a221c6d8ae9ade2 (diff)
downloadinvidious-2a6c81a89dd64c00dff0b37af1c6637771aad27e.tar.gz
invidious-2a6c81a89dd64c00dff0b37af1c6637771aad27e.tar.bz2
invidious-2a6c81a89dd64c00dff0b37af1c6637771aad27e.zip
Add authentication API
Diffstat (limited to 'src')
-rw-r--r--src/invidious.cr359
-rw-r--r--src/invidious/helpers/handlers.cr61
-rw-r--r--src/invidious/helpers/tokens.cr146
-rw-r--r--src/invidious/users.cr81
-rw-r--r--src/invidious/videos.cr18
-rw-r--r--src/invidious/views/authorize_token.ecr78
-rw-r--r--src/invidious/views/clear_watch_history.ecr2
-rw-r--r--src/invidious/views/components/item.ecr2
-rw-r--r--src/invidious/views/components/subscribe_widget.ecr4
-rw-r--r--src/invidious/views/components/subscribe_widget_script.ecr4
-rw-r--r--src/invidious/views/delete_account.ecr2
-rw-r--r--src/invidious/views/history.ecr4
-rw-r--r--src/invidious/views/preferences.ecr4
-rw-r--r--src/invidious/views/subscription_manager.ecr18
-rw-r--r--src/invidious/views/subscriptions.ecr2
-rw-r--r--src/invidious/views/template.ecr2
-rw-r--r--src/invidious/views/token_manager.ecr72
17 files changed, 715 insertions, 144 deletions
diff --git a/src/invidious.cr b/src/invidious.cr
index 299ef8bc..33f35afa 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -197,16 +197,20 @@ before_all do |env|
if env.request.cookies.has_key? "SID"
sid = env.request.cookies["SID"].value
+ if sid.starts_with? "v1:"
+ raise "Cannot use token as SID"
+ end
+
# Invidious users only have SID
if !env.request.cookies.has_key? "SSID"
if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
- token = create_response(sid, {"signout", "watch_ajax", "subscription_ajax"}, HMAC_KEY, PG_DB, 1.week)
+ csrf_token = generate_response(sid, {":signout", ":watch_ajax", ":subscription_ajax", ":token_ajax", ":authorize_token"}, HMAC_KEY, PG_DB, 1.week)
preferences = user.preferences
env.set "sid", sid
- env.set "token", token
+ env.set "csrf_token", csrf_token
env.set "user", user
end
else
@@ -215,12 +219,12 @@ before_all do |env|
begin
user, sid = get_user(sid, headers, PG_DB, false)
- token = create_response(sid, {"signout", "watch_ajax", "subscription_ajax"}, HMAC_KEY, PG_DB, 1.week)
+ csrf_token = generate_response(sid, {":signout", ":watch_ajax", ":subscription_ajax", ":token_ajax", ":authorize_token"}, HMAC_KEY, PG_DB, 1.week)
preferences = user.preferences
env.set "sid", sid
- env.set "token", token
+ env.set "csrf_token", csrf_token
env.set "user", user
rescue ex
end
@@ -1096,9 +1100,10 @@ post "/login" do |env|
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
begin
- validate_response(tokens[0], answer, env.request.path, HMAC_KEY, PG_DB, locale)
+ validate_request(tokens[0], answer, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
error_message = ex.message
+ env.response.status_code = 400
next templated "error"
end
when "text"
@@ -1109,7 +1114,7 @@ post "/login" do |env|
error_message = translate(locale, "Invalid CAPTCHA")
tokens.each_with_index do |token, i|
begin
- validate_response(token, answer, env.request.path, HMAC_KEY, PG_DB, locale)
+ validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale)
found_valid_captcha = true
rescue ex
error_message = ex.message
@@ -1189,12 +1194,13 @@ post "/signout" do |env|
if user
user = user.as(User)
sid = sid.as(String)
- token = env.params.body["token"]?
+ token = env.params.body["csrf_token"]?
begin
- validate_response(token, sid, env.request.path, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
error_message = ex.message
+ env.response.status_code = 400
next templated "error"
end
@@ -1424,12 +1430,18 @@ post "/watch_ajax" do |env|
redirect = redirect == "true"
if !user
- next env.redirect referer
+ if redirect
+ next env.redirect referer
+ else
+ error_message = {"error" => "No such user"}.to_json
+ env.response.status_code = 403
+ next error_message
+ end
end
user = user.as(User)
sid = sid.as(String)
- token = env.params.body["token"]?
+ token = env.params.body["csrf_token"]?
id = env.params.query["id"]?
if !id
@@ -1437,19 +1449,16 @@ post "/watch_ajax" do |env|
next
end
- user = user.as(User)
- sid = sid.as(String)
- token = env.params.body["token"]?
-
begin
- validate_response(token, sid, env.request.path, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
if redirect
error_message = ex.message
+ env.response.status_code = 400
next templated "error"
else
error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
+ env.response.status_code = 400
next error_message
end
end
@@ -1494,8 +1503,14 @@ get "/modify_notifications" do |env|
redirect ||= "false"
redirect = redirect == "true"
- if !user && !sid
- next env.redirect referer
+ if !user
+ if redirect
+ next env.redirect referer
+ else
+ error_message = {"error" => "No such user"}.to_json
+ env.response.status_code = 403
+ next error_message
+ end
end
user = user.as(User)
@@ -1566,22 +1581,29 @@ post "/subscription_ajax" do |env|
redirect = redirect == "true"
if !user
- next env.redirect referer
+ if redirect
+ next env.redirect referer
+ else
+ error_message = {"error" => "No such user"}.to_json
+ env.response.status_code = 403
+ next error_message
+ end
end
user = user.as(User)
sid = sid.as(String)
- token = env.params.body["token"]?
+ token = env.params.body["csrf_token"]?
begin
- validate_response(token, sid, env.request.path, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
if redirect
error_message = ex.message
+ env.response.status_code = 400
next templated "error"
else
error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
+ env.response.status_code = 400
next error_message
end
end
@@ -1660,9 +1682,9 @@ get "/subscription_manager" do |env|
user = env.get? "user"
sid = env.get? "sid"
- referer = get_referer(env, "/subscription_manager")
+ referer = get_referer(env)
- if !user && !sid
+ if !user
next env.redirect referer
end
@@ -1856,7 +1878,7 @@ get "/delete_account" do |env|
if user
user = user.as(User)
sid = sid.as(String)
- token = create_response(sid, {"delete_account"}, HMAC_KEY, PG_DB)
+ csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY, PG_DB)
templated "delete_account"
else
@@ -1874,12 +1896,13 @@ post "/delete_account" do |env|
if user
user = user.as(User)
sid = sid.as(String)
- token = env.params.body["token"]?
+ token = env.params.body["csrf_token"]?
begin
- validate_response(token, sid, env.request.path, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
error_message = ex.message
+ env.response.status_code = 400
next templated "error"
end
@@ -1907,7 +1930,7 @@ get "/clear_watch_history" do |env|
if user
user = user.as(User)
sid = sid.as(String)
- token = create_response(sid, {"clear_watch_history"}, HMAC_KEY, PG_DB)
+ csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY, PG_DB)
templated "clear_watch_history"
else
@@ -1925,12 +1948,13 @@ post "/clear_watch_history" do |env|
if user
user = user.as(User)
sid = sid.as(String)
- token = env.params.body["token"]?
+ token = env.params.body["csrf_token"]?
begin
- validate_response(token, sid, env.request.path, HMAC_KEY, PG_DB, locale)
+ validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
error_message = ex.message
+ env.response.status_code = 400
next templated "error"
end
@@ -1940,6 +1964,137 @@ post "/clear_watch_history" do |env|
env.redirect referer
end
+# TODO?
+# get "/authorize_token" do |env|
+# ...
+# end
+
+post "/authorize_token" do |env|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ if user
+ user = env.get("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
+ error_message = ex.message
+ env.response.status_code = 400
+ next templated "error"
+ end
+
+ scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
+ callback_url = env.params.body["callbackUrl"]?
+ expire = env.params.body["expire"]?.try &.to_i?
+
+ access_token = generate_token(user.email, scopes, expire, HMAC_KEY, PG_DB)
+
+ if callback_url
+ access_token = URI.escape(access_token)
+ url = URI.parse(callback_url)
+
+ if url.query
+ query = HTTP::Params.parse(url.query.not_nil!)
+ else
+ query = HTTP::Params.new
+ end
+
+ query["token"] = access_token
+ url.query = query.to_s
+
+ env.redirect url.to_s
+ else
+ csrf_token = ""
+ env.set "access_token", access_token
+ templated "authorize_token"
+ end
+ end
+end
+
+get "/token_manager" do |env|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env, "/subscription_manager")
+
+ if !user
+ next env.redirect referer
+ end
+
+ user = user.as(User)
+
+ tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1 ORDER BY issued DESC", user.email, as: {session: String, issued: Time})
+
+ templated "token_manager"
+end
+
+post "/token_ajax" do |env|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ redirect = env.params.query["redirect"]?
+ redirect ||= "true"
+ redirect = redirect == "true"
+
+ if !user
+ if redirect
+ next env.redirect referer
+ else
+ error_message = {"error" => "No such user"}.to_json
+ env.response.status_code = 403
+ next error_message
+ end
+ 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
+ if redirect
+ error_message = ex.message
+ next templated "error"
+ else
+ error_message = {"error" => ex.message}.to_json
+ env.response.status_code = 400
+ next error_message
+ end
+ end
+
+ if env.params.query["action_revoke_token"]?
+ action = "action_revoke_token"
+ else
+ next env.redirect referer
+ end
+
+ session = env.params.query["session"]?
+ session ||= ""
+
+ case action
+ when .starts_with? "action_revoke_token"
+ PG_DB.exec("DELETE FROM session_ids * WHERE id = $1 AND email = $2", session, user.email)
+ end
+
+ if redirect
+ env.redirect referer
+ else
+ env.response.content_type = "application/json"
+ "{}"
+ end
+end
+
# Feeds
get "/feed/top" do |env|
@@ -4127,6 +4282,142 @@ get "/api/v1/mixes/:rdid" do |env|
response
end
+# TODO
+# get "/api/v1/auth/preferences" do |env|
+# ...
+# end
+
+# TODO
+# post "/api/v1/auth/preferences" do |env|
+# ...
+# end
+
+# TODO
+# get "/api/v1/auth/subscriptions" do |env|
+# ...
+# end
+
+# TODO
+# post "/api/v1/auth/subscriptions/:ucid" do |env|
+# ...
+# end
+
+# TODO
+# delete "/api/v1/auth/subscriptions/:ucid" do |env|
+# ...
+# end
+
+get "/api/v1/auth/tokens" do |env|
+ env.response.content_type = "application/json"
+ user = env.get("user").as(User)
+ scopes = env.get("scopes").as(Array(String))
+
+ tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time})
+
+ # Only allow user sessions to view other user sessions
+ # if !scopes.includes? [":*"]
+ # tokens.select { |token| token[:session].starts_with? "v1:" }
+ # end
+
+ JSON.build do |json|
+ json.array do
+ tokens.each do |token|
+ json.object do
+ json.field "session", token[:session]
+ json.field "issued", token[:issued].to_unix
+ end
+ end
+ end
+ end
+end
+
+post "/api/v1/auth/tokens/register" do |env|
+ user = env.get("user").as(User)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ case env.request.headers["Content-Type"]?
+ when "application/x-www-form-urlencoded"
+ scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
+ callback_url = env.params.body["callbackUrl"]?
+ expire = env.params.body["expire"]?.try &.to_i?
+ when "application/json"
+ scopes = env.params.json["scopes"].as(Array).map { |v| v.as_s }
+ callback_url = env.params.json["callbackUrl"]?.try &.as(String)
+ expire = env.params.json["expire"]?.try &.as(Int64)
+ else
+ error_message = {"error" => "Invalid or missing header 'Content-Type'"}.to_json
+ env.response.status_code = 400
+ next error_message
+ end
+
+ if callback_url && callback_url.empty?
+ callback_url = nil
+ end
+
+ if callback_url
+ callback_url = URI.parse(callback_url)
+ end
+
+ if sid = env.get?("sid").try &.as(String)
+ env.response.content_type = "text/html"
+
+ csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true)
+ next templated "authorize_token"
+ else
+ env.response.content_type = "application/json"
+
+ superset_scopes = env.get("scopes").as(Array(String))
+
+ authorized_scopes = [] of String
+ scopes.each do |scope|
+ if scopes_include_scope(superset_scopes, scope)
+ authorized_scopes << scope
+ end
+ end
+
+ access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB)
+
+ if callback_url
+ access_token = URI.escape(access_token)
+
+ if query = callback_url.query
+ query = HTTP::Params.parse(query.not_nil!)
+ else
+ query = HTTP::Params.new
+ end
+
+ query["token"] = access_token
+ callback_url.query = query.to_s
+
+ env.redirect callback_url.to_s
+ else
+ access_token
+ end
+ end
+end
+
+post "/api/v1/auth/tokens/unregister" do |env|
+ env.response.content_type = "application/json"
+ user = env.get("user").as(User)
+ scopes = env.get("scopes").as(Array(String))
+
+ session = env.params.json["session"]?.try &.as(String)
+ session ||= env.get("session").as(String)
+
+ # Allow tokens to revoke other tokens with correct scope
+ if session == env.get("session").as(String)
+ PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session)
+ elsif scopes_include_scope(scopes, "GET:tokens")
+ PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session)
+ else
+ error_message = {"error" => "Cannot revoke session #{session}"}.to_json
+ env.response.status_code = 400
+ next error_message
+ end
+
+ env.response.status_code = 204
+end
+
get "/api/manifest/dash/id/videoplayback" do |env|
env.response.headers.delete("Content-Type")
env.response.headers["Access-Control-Allow-Origin"] = "*"
@@ -4708,8 +4999,8 @@ error 404 do |env|
end
# Check if item is video ID
- client = make_client(URI.parse("https://youtu.be"))
- if client.head("/#{item}").status_code != 404
+ client = make_client(YT_URL)
+ if item.match(/^[a-zA-Z0-9_-]{11}$/) && client.head("/watch?v=#{item}").status_code != 404
env.response.headers["Location"] = url
halt env, status_code: 302
end
@@ -4760,9 +5051,11 @@ public_folder "assets"
Kemal.config.powered_by_header = false
add_handler FilteredCompressHandler.new
add_handler APIHandler.new
+add_handler AuthHandler.new
add_handler DenyFrame.new
-add_context_storage_type(User)
+add_context_storage_type(Array(String))
add_context_storage_type(Preferences)
+add_context_storage_type(User)
Kemal.config.logger = logger
Kemal.run
diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr
index b613488a..0c1b7bd2 100644
--- a/src/invidious/helpers/handlers.cr
+++ b/src/invidious/helpers/handlers.cr
@@ -20,7 +20,9 @@ module HTTP::Handler
end
class Kemal::RouteHandler
- exclude ["/api/v1/*"]
+ {% for method in %w(GET POST PUT HEAD DELETE PATCH OPTIONS) %}
+ exclude ["/api/v1/*"], {{method}}
+ {% end %}
# Processes the route if it's a match. Otherwise renders 404.
private def process_request(context)
@@ -37,7 +39,9 @@ class Kemal::RouteHandler
end
class Kemal::ExceptionHandler
- exclude ["/api/v1/*"]
+ {% for method in %w(GET POST PUT HEAD DELETE PATCH OPTIONS) %}
+ exclude ["/api/v1/*"], {{method}}
+ {% end %}
private def call_exception_with_status_code(context : HTTP::Server::Context, exception : Exception, status_code : Int32)
return if context.response.closed?
@@ -76,8 +80,59 @@ class FilteredCompressHandler < Kemal::Handler
end
end
+class AuthHandler < Kemal::Handler
+ {% for method in %w(GET POST PUT HEAD DELETE PATCH OPTIONS) %}
+ only ["/api/v1/auth/*"], {{method}}
+ {% end %}
+
+ def call(env)
+ return call_next env unless only_match? env
+
+ begin
+ if token = env.request.headers["Authorization"]?
+ token = JSON.parse(URI.unescape(token.lchop("Bearer ")))
+ session = URI.unescape(token["session"].as_s)
+ scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, PG_DB, nil)
+
+ if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", session, as: String)
+ user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
+ end
+ elsif sid = env.request.cookies["SID"]?.try &.value
+ if sid.starts_with? "v1:"
+ raise "Cannot use token as SID"
+ end
+
+ if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
+ user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
+ end
+
+ scopes = [":*"]
+ session = sid
+ end
+
+ if !user
+ raise "Request must be authenticated"
+ end
+
+ env.set "scopes", scopes
+ env.set "user", user
+ env.set "session", session
+
+ call_next env
+ rescue ex
+ env.response.content_type = "application/json"
+
+ error_message = {"error" => ex.message}.to_json
+ env.response.status_code = 403
+ env.response.puts error_message
+ end
+ end
+end
+
class APIHandler < Kemal::Handler
- only ["/api/v1/*"]
+ {% for method in %w(GET POST PUT HEAD DELETE PATCH OPTIONS) %}
+ only ["/api/v1/*"], {{method}}
+ {% end %}
def call(env)
return call_next env unless only_match? env
diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr
new file mode 100644
index 00000000..6841127a
--- /dev/null
+++ b/src/invidious/helpers/tokens.cr
@@ -0,0 +1,146 @@
+def generate_token(email, scopes, expire, key, db)
+ session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
+ PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.now)
+
+ token = {
+ "session" => session,
+ "scopes" => scopes,
+ "expire" => expire,
+ }
+
+ if !expire
+ token.delete("expire")
+ end
+
+ token["signature"] = sign_token(key, token)
+
+ return token.to_json
+end
+
+def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false)
+ expire = Time.now + expire
+
+ token = {
+ "session" => session,
+ "expire" => expire.to_unix,
+ "scopes" => scopes,
+ }
+
+ if use_nonce
+ nonce = Random::Secure.hex(16)
+ db.exec("INSERT INTO nonces VALUES ($1, $2) ON CONFLICT DO NOTHING", nonce, expire)
+ token["nonce"] = nonce
+ end
+
+ token["signature"] = sign_token(key, token)
+
+ return token.to_json
+end
+
+def sign_token(key, hash)
+ string_to_sign = [] of String
+
+ hash.each do |key, value|
+ if key == "signature"
+ next
+ end
+
+ if value.is_a?(JSON::Any)
+ case value
+ when .as_a?
+ value = value.as_a.map { |item| item.as_s }
+ end
+ end
+
+ case value
+ when Array
+ string_to_sign << "#{key}=#{value.sort.join(",")}"
+ when Tuple
+ string_to_sign << "#{key}=#{value.to_a.sort.join(",")}"
+ else
+ string_to_sign << "#{key}=#{value}"
+ end
+ end
+
+ string_to_sign = string_to_sign.sort.join("\n")
+ return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip
+end
+
+def validate_request(token, session, request, key, db, locale = nil)
+ case token
+ when String
+ token = JSON.parse(URI.unescape(token)).as_h
+ when JSON::Any
+ token = token.as_h
+ when Nil
+ raise translate(locale, "Hidden field \"token\" is a required field")
+ end
+
+ if token["signature"] != sign_token(key, token)
+ raise translate(locale, "Invalid signature")
+ end
+
+ if token["session"] != session
+ raise translate(locale, "Invalid token")
+ end
+
+ if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time}))
+ if nonce[1] > Time.now
+ db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.new(1990, 1, 1), nonce[0])
+ else
+ raise translate(locale, "Invalid token")
+ end
+ end
+
+ scopes = token["scopes"].as_a.map { |v| v.as_s }
+ scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}"
+
+ if !scopes_include_scope(scopes, scope)
+ raise translate(locale, "Invalid scope")
+ end
+
+ expire = token["expire"]?.try &.as_i
+ if expire.try &.< Time.now.to_unix
+ raise translate(locale, "Token is expired, please try again")
+ end
+
+ return {scopes, expire, token["signature"].as_s}
+end
+
+def scope_includes_scope(scope, subset)
+ methods, endpoint = scope.split(":")
+ methods = methods.split(";").map { |method| method.upcase }.reject { |method| method.empty? }.sort
+ endpoint = endpoint.downcase
+
+ subset_methods, subset_endpoint = subset.split(":")
+ subset_methods = subset_methods.split(";").map { |method| method.upcase }.sort
+ subset_endpoint = subset_endpoint.downcase
+
+ if methods.empty?
+ methods = %w(GET POST PUT HEAD DELETE PATCH OPTIONS)
+ end
+
+ if methods & subset_methods != subset_methods
+ return false
+ end
+
+ if endpoint.ends_with?("*") && !subset_endpoint.starts_with? endpoint.rchop("*")
+ return false
+ end
+
+ if !endpoint.ends_with?("*") && subset_endpoint != endpoint
+ return false
+ end
+
+ return true
+end
+
+def scopes_include_scope(scopes, subset)
+ scopes.each do |scope|
+ if scope_includes_scope(scope, subset)
+ return true
+ end
+ end
+
+ return false
+end
diff --git a/src/invidious/users.cr b/src/invidious/users.cr
index 689d9434..43a55eac 100644
--- a/src/invidious/users.cr
+++ b/src/invidious/users.cr
@@ -197,83 +197,6 @@ def create_user(sid, email, password)
return user, sid
end
-def create_response(session, scopes, key, db, expire = 6.hours, use_nonce = false)
- expire = Time.now + expire
-
- token = {
- "session" => session,
- "expire" => expire.to_unix,
- "scopes" => scopes,
- }
-
- if use_nonce
- nonce = Random::Secure.hex(16)
- db.exec("INSERT INTO nonces VALUES ($1, $2) ON CONFLICT DO NOTHING", nonce, expire)
- token["nonce"] = nonce
- end
-
- token["signature"] = sign_token(key, token)
-
- return token.to_json
-end
-
-def sign_token(key, hash)
- string_to_sign = [] of String
-
- hash.each do |key, value|
- if key == "signature"
- next
- end
-
- if value.is_a?(JSON::Any)
- case value
- when .as_a?
- value = value.as_a.map { |item| item.as_s }
- end
- end
-
- case value
- when Array
- string_to_sign << "#{key}=#{value.sort.join(",")}"
- when Tuple
- string_to_sign << "#{key}=#{value.to_a.sort.join(",")}"
- else
- string_to_sign << "#{key}=#{value}"
- end
- end
-
- string_to_sign = string_to_sign.sort.join("\n")
- return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip
-end
-
-def validate_response(token, session, scope, key, db, locale)
- if !token
- raise translate(locale, "Hidden field \"token\" is a required field")
- end
-
- token = JSON.parse(URI.unescape(token)).as_h
-
- if token["signature"]? != sign_token(key, token)
- raise translate(locale, "Invalid token")
- end
-
- if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time}))
- if nonce[1] > Time.now
- db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.new(1990, 1, 1), nonce[0])
- else
- raise translate(locale, "Invalid token")
- end
- end
-
- if !token["scopes"].as_a.includes? scope.strip("/")
- raise translate(locale, "Invalid token")
- end
-
- if token["expire"].as_i < Time.now.to_unix
- raise translate(locale, "Token is expired, please try again")
- end
-end
-
def generate_captcha(key, db)
second = Random::Secure.rand(12)
second_angle = second * 30
@@ -326,7 +249,7 @@ def generate_captcha(key, db)
return {
question: image,
- tokens: {create_response(answer, {"login"}, key, db, use_nonce: true)},
+ tokens: {generate_response(answer, {":login"}, key, db, use_nonce: true)},
}
end
@@ -335,7 +258,7 @@ def generate_text_captcha(key, db)
response = JSON.parse(response)
tokens = response["a"].as_a.map do |answer|
- create_response(answer.as_s, {"login"}, key, db, use_nonce: true)
+ generate_response(answer.as_s, {":login"}, key, db, use_nonce: true)
end
return {
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index c72458b2..bdd21a48 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -486,15 +486,15 @@ struct Video
if storyboard = storyboards.try &.["spec"]?
.try &.as_s
return [{
- url: storyboard.split("#")[0],
- width: 106,
- height: 60,
- count: -1,
- interval: 5000,
- storyboard_width: 3,
- storyboard_height: 3,
- storyboard_count: -1,
- }]
+ url: storyboard.split("#")[0],
+ width: 106,
+ height: 60,
+ count: -1,
+ interval: 5000,
+ storyboard_width: 3,
+ storyboard_height: 3,
+ storyboard_count: -1,
+ }]
end
end
diff --git a/src/invidious/views/authorize_token.ecr b/src/invidious/views/authorize_token.ecr
new file mode 100644
index 00000000..d00335e2
--- /dev/null
+++ b/src/invidious/views/authorize_token.ecr
@@ -0,0 +1,78 @@
+<% content_for "header" do %>
+<title><%= translate(locale, "Token") %> - Invidious</title>
+<% end %>
+
+<% if env.get? "access_token" %>
+<div class="pure-g h-box">
+ <div class="pure-u-1-3">
+ <h3>
+ <%= translate(locale, "Token") %>
+ </h3>
+ </div>
+ <div class="pure-u-1-3" style="text-align:center">
+ <h3>
+ <a href="/token_manager"><%= translate(locale, "Token manager") %></a>
+ </h3>
+ </div>
+ <div class="pure-u-1-3" style="text-align:right">
+ <h3>
+ <a href="/preferences"><%= translate(locale, "Preferences") %></a>
+ </h3>
+ </div>
+</div>
+
+<div class="h-box">
+ <h4 style="padding-left:0.5em">
+ <code><%= env.get "access_token" %></code>
+ </h4>
+</div>
+<% else %>
+<div class="h-box">
+ <form class="pure-form pure-form-aligned" action="/authorize_token" method="post">
+ <% if callback_url %>
+ <legend><%= translate(locale, "Authorize token for `x`?", "#{callback_url.scheme}://#{callback_url.host}") %></legend>
+ <% else %>
+ <legend><%= translate(locale, "Authorize token?") %></legend>
+ <% end %>
+
+ <div class="pure-g">
+ <div class="pure-u-1">
+ <ul>
+ <% scopes.each do |scope| %>
+ <li><%= scope %></li>
+ <% end %>
+ </ul>
+ </div>
+ </div>
+
+ <div class="pure-g">
+ <div class="pure-u-1-2">
+ <button type="submit" name="submit" value="clear_watch_history" class="pure-button pure-button-primary">
+ <%= translate(locale, "Yes") %>
+ </button>
+ </div>
+ <div class="pure-u-1-2">
+ <% if callback_url %>
+ <a class="pure-button" href="<%= callback_url %>">
+ <% else %>
+ <a class="pure-button" href="/">
+ <% end %>
+ <%= translate(locale, "No") %>
+ </a>
+ </div>
+ </div>
+
+ <% scopes.each_with_index do |scope, i| %>
+ <input type="hidden" name="scopes[<%= i %>]" value="<%= scope %>">
+ <% end %>
+ <% if callback_url %>
+ <input type="hidden" name="callbackUrl" value="<%= callback_url %>">
+ <% end %>
+ <% if expire %>
+ <input type="hidden" name="expire" value="<%= expire %>">
+ <% end %>
+
+ <input type="hidden" name="csrf_token" value="<%= URI.escape(csrf_token) %>">
+ </form>
+</div>
+<% end %> \ No newline at end of file
diff --git a/src/invidious/views/clear_watch_history.ecr b/src/invidious/views/clear_watch_history.ecr
index 55555bee..ea6eb1fc 100644
--- a/src/invidious/views/clear_watch_history.ecr
+++ b/src/invidious/views/clear_watch_history.ecr
@@ -19,6 +19,6 @@
</div>
</div>
- <input type="hidden" name="token" value="<%= URI.escape(token) %>">
+ <input type="hidden" name="csrf_token" value="<%= URI.escape(csrf_token) %>">
</form>
</div>
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index ae1ccb96..469dd230 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -86,7 +86,7 @@
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if env.get? "show_watched" %>
<form onsubmit="return false;" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>">
+ <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched">
<a onclick="mark_watched(this)" data-id="<%= item.id %>" href="#">
<button type="submit" style="all:unset">
diff --git a/src/invidious/views/components/subscribe_widget.ecr b/src/invidious/views/components/subscribe_widget.ecr
index 59849c1a..7988d2f1 100644
--- a/src/invidious/views/components/subscribe_widget.ecr
+++ b/src/invidious/views/components/subscribe_widget.ecr
@@ -2,7 +2,7 @@
<% if subscriptions.includes? ucid %>
<p>
<form onsubmit="return false;" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>">
+ <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a id="subscribe" onclick="unsubscribe()" class="pure-button pure-button-primary" href="#">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
</a>
@@ -11,7 +11,7 @@
<% else %>
<p>
<form onsubmit="return false;" action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>">
+ <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a id="subscribe" onclick="subscribe()" class="pure-button pure-button-primary" href="#">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
</a>
diff --git a/src/invidious/views/components/subscribe_widget_script.ecr b/src/invidious/views/components/subscribe_widget_script.ecr
index 8fe0a653..f8c7416a 100644
--- a/src/invidious/views/components/subscribe_widget_script.ecr
+++ b/src/invidious/views/components/subscribe_widget_script.ecr
@@ -17,7 +17,7 @@ function subscribe(timeouts = 0) {
xhr.timeout = 20000;
xhr.open("POST", url, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
- xhr.send("token=<%= URI.escape(env.get?("token").try &.as(String) || "") %>");
+ xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>");
var fallback = subscribe_button.innerHTML;
subscribe_button.onclick = unsubscribe;
@@ -53,7 +53,7 @@ function unsubscribe(timeouts = 0) {
xhr.timeout = 20000;
xhr.open("POST", url, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
- xhr.send("token=<%= URI.escape(env.get?("token").try &.as(String) || "") %>");
+ xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>");
var fallback = subscribe_button.innerHTML;
subscribe_button.onclick = subscribe;
diff --git a/src/invidious/views/delete_account.ecr b/src/invidious/views/delete_account.ecr
index f49eaab9..7cfb9bfa 100644
--- a/src/invidious/views/delete_account.ecr
+++ b/src/invidious/views/delete_account.ecr
@@ -19,6 +19,6 @@
</div>
</div>
- <input type="hidden" name="token" value="<%= URI.escape(token) %>">
+ <input type="hidden" name="csrf_token" value="<%= URI.escape(csrf_token) %>">
</form>
</div>
diff --git a/src/invidious/views/history.ecr b/src/invidious/views/history.ecr
index 15a24f4d..a207145b 100644
--- a/src/invidious/views/history.ecr
+++ b/src/invidious/views/history.ecr
@@ -29,7 +29,7 @@
<div class="thumbnail">
<img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg"/>
<form onsubmit="return false;" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>">
+ <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<p class="watched">
<a onclick="mark_unwatched(this)" data-id="<%= item %>" href="#">
<button type="submit" style="all:unset">
@@ -61,7 +61,7 @@ function mark_unwatched(target) {
xhr.timeout = 20000;
xhr.open("POST", url, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
- xhr.send("token=<%= URI.escape(env.get?("token").try &.as(String) || "") %>");
+ xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>");
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr
index 881d6fd4..5dfe779b 100644
--- a/src/invidious/views/preferences.ecr
+++ b/src/invidious/views/preferences.ecr
@@ -217,6 +217,10 @@ function update_value(element) {
</div>
<div class="pure-control-group">
+ <a href="/token_manager"><%= translate(locale, "Manage tokens") %></a>
+ </div>
+
+ <div class="pure-control-group">
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
</div>
diff --git a/src/invidious/views/subscription_manager.ecr b/src/invidious/views/subscription_manager.ecr
index c9b90189..54fbb70e 100644
--- a/src/invidious/views/subscription_manager.ecr
+++ b/src/invidious/views/subscription_manager.ecr
@@ -8,12 +8,12 @@
<a href="/feed/subscriptions"><%= translate(locale, "`x` subscriptions", %(<span id="count">#{subscriptions.size}</span>)) %></a>
</h3>
</div>
- <div class="pure-u-1-3" style="text-align:center;">
+ <div class="pure-u-1-3" style="text-align:center">
<h3>
<a href="/feed/history"><%= translate(locale, "Watch history") %></a>
</h3>
</div>
- <div class="pure-u-1-3" style="text-align:right;">
+ <div class="pure-u-1-3" style="text-align:right">
<h3>
<a href="/data_control?referer=<%= referer %>"><%= translate(locale, "Import/Export") %></a>
</h3>
@@ -22,17 +22,17 @@
<% subscriptions.each do |channel| %>
<div class="h-box">
- <div class="pure-g<% if channel.deleted %> deleted <% end%>">
+ <div class="pure-g<% if channel.deleted %> deleted <% end %>">
<div class="pure-u-2-5">
- <h3 style="padding-left: 0.5em">
+ <h3 style="padding-left:0.5em">
<a href="/channel/<%= channel.id %>"><%= channel.author %></a>
</h3>
</div>
<div class="pure-u-2-5"></div>
- <div class="pure-u-1-5" style="text-align: right;">
- <h3 style="padding-right: 0.5em">
- <form onsubmit="return false;" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>">
+ <div class="pure-u-1-5" style="text-align:right">
+ <h3 style="padding-right:0.5em">
+ <form onsubmit="return false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
+ <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a onclick="remove_subscription(this)" data-ucid="<%= channel.id %>" href="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>">
</a>
@@ -60,7 +60,7 @@ function remove_subscription(target) {
xhr.timeout = 20000;
xhr.open("POST", url, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
- xhr.send("token=<%= URI.escape(env.get?("token").try &.as(String) || "") %>");
+ xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>");
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
diff --git a/src/invidious/views/subscriptions.ecr b/src/invidious/views/subscriptions.ecr
index 7fc83b6d..5f0f7680 100644
--- a/src/invidious/views/subscriptions.ecr
+++ b/src/invidious/views/subscriptions.ecr
@@ -62,7 +62,7 @@ function mark_watched(target) {
xhr.timeout = 20000;
xhr.open("POST", url, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
- xhr.send("token=<%= URI.escape(env.get?("token").try &.as(String) || "") %>");
+ xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>");
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index 6ab3936c..89780ef7 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -70,7 +70,7 @@
</div>
<div class="pure-u-1-4">
<form action="/signout?referer=<%= env.get?("current_page") %>" method="post">
- <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>">
+ <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a class="pure-menu-heading" href="#">
<input style="all:unset" type="submit" value="<%= translate(locale, "Sign out") %>">
</a>
diff --git a/src/invidious/views/token_manager.ecr b/src/invidious/views/token_manager.ecr
new file mode 100644
index 00000000..91d0ddb4
--- /dev/null
+++ b/src/invidious/views/token_manager.ecr
@@ -0,0 +1,72 @@
+<% content_for "header" do %>
+<title><%= translate(locale, "Token manager") %> - Invidious</title>
+<% end %>
+
+<div class="pure-g h-box">
+ <div class="pure-u-1-3">
+ <h3>
+ <%= translate(locale, "`x` tokens", %(<span id="count">#{tokens.size}</span>)) %>
+ </h3>
+ </div>
+ <div class="pure-u-1-3"></div>
+ <div class="pure-u-1-3" style="text-align:right">
+ <h3>
+ <a href="/preferences?referer=<%= referer %>"><%= translate(locale, "Preferences") %></a>
+ </h3>
+ </div>
+</div>
+
+<% tokens.each do |token| %>
+<div class="h-box">
+ <div class="pure-g<% if token[:session] == sid %> deleted <% end %>">
+ <div class="pure-u-3-5">
+ <h4 style="padding-left:0.5em">
+ <code><%= token[:session] %></code>
+ </h4>
+ </div>
+ <div class="pure-u-1-5" style="text-align:center">
+ <h4><%= translate(locale, "`x` ago", recode_date(token[:issued], locale)) %></h4>
+ </div>
+ <div class="pure-u-1-5" style="text-align:right">
+ <h3 style="padding-right:0.5em">
+ <form onsubmit="return false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
+ <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>">
+ <a onclick="revoke_token(this)" data-session="<%= token[:session] %>" href="#">
+ <input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>">
+ </a>
+ </form>
+ </h3>
+ </div>
+ </div>
+
+ <% if tokens[-1].try &.[:session]? != token[:session] %>
+ <hr>
+ <% end %>
+</div>
+<% end %>
+
+<script>
+function revoke_token(target) {
+ var row = target.parentNode.parentNode.parentNode.parentNode.parentNode;
+ row.style.display = "none";
+ var count = document.getElementById("count")
+ count.innerText = count.innerText - 1;
+
+ var url = "/token_ajax?action_revoke_token=1&redirect=false&referer=<%= env.get("current_page") %>&session=" + target.getAttribute("data-session");
+ var xhr = new XMLHttpRequest();
+ xhr.responseType = "json";
+ xhr.timeout = 20000;
+ xhr.open("POST", url, true);
+ xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
+ xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>");
+
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState == 4) {
+ if (xhr.status != 200) {
+ count.innerText = count.innerText - 1 + 2;
+ row.style.display = "";
+ }
+ }
+ }
+}
+</script> \ No newline at end of file