From bbbe7e792fdf7918f473345a941e751491112daa Mon Sep 17 00:00:00 2001 From: Ryan G <78384369+SpongeManiac@users.noreply.github.com> Date: Mon, 12 Jun 2023 18:23:46 -0700 Subject: [PATCH] Added Registration API Added authentication.cr Updated routes.cr Updated captcha.cr Updated tokens.cr --- src/invidious/helpers/tokens.cr | 8 +- src/invidious/routes/api/v1/authentication.cr | 184 ++++++++++++++++++ src/invidious/routing.cr | 5 + src/invidious/user/captcha.cr | 4 +- 4 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 src/invidious/routes/api/v1/authentication.cr diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index a44988cd..f125a487 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -86,7 +86,13 @@ def validate_request(token, session, request, key, locale = nil) end scopes = token["scopes"].as_a.map(&.as_s) - scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}" + if request.path.includes?("auth") + scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}" + elsif request.path.includes?("v1") + scope = "#{request.method}:#{request.path.lchop("/api/v1/").lstrip("/")}" + else + scope = "#{request.method}:#{request.path.lstrip("/")}" + end if !scopes_include_scope(scopes, scope) raise InfoException.new("Invalid scope") end diff --git a/src/invidious/routes/api/v1/authentication.cr b/src/invidious/routes/api/v1/authentication.cr new file mode 100644 index 00000000..4b39087b --- /dev/null +++ b/src/invidious/routes/api/v1/authentication.cr @@ -0,0 +1,184 @@ +require "json" + +module Invidious::Routes::API::V1::Authentication + def self.api_register(env) + env.response.content_type = "application/json" + body_io = env.request.body + body_json = "{}" + if body_io + body_json = env.request.body.not_nil!.gets_to_end + end + if CONFIG.registration_enabled + creds = nil + begin + creds = Credentials.from_json(body_json) + rescue JSON::SerializableError + end + # get user info + if creds + locale = env.get("preferences").as(Preferences).locale + username = creds.username.downcase + password = creds.password + username = "" if username.nil? + password = "" if password.nil? + + if username.empty? + return error_json(401, "Username cannot be empty") + end + + if password.empty? + return error_json(401, "Password cannot be empty") + end + + if username.bytesize > 254 + return error_json(401, "Username cannot be longer than 254 characters") + end + + # See https://security.stackexchange.com/a/39851 + if password.bytesize > 55 + return error_json(401, "Password cannot be longer than 55 characters") + end + + username = username.byte_slice(0, 254) + password = password.byte_slice(0, 55) + # send captcha if enabled + if CONFIG.captcha_enabled + captcha_response = nil + begin + captcha_response = CaptchaResponse.from_json(body_json) + rescue JSON::SerializableError + end + if captcha_response + answer = captcha_response.answer + tokens = captcha_response.tokens + answer = Digest::MD5.hexdigest(answer.downcase.strip) + if tokens.empty? + return error_json(500, "Erroneous CAPTCHA") + end + + found_valid_captcha = false + error_exception = Exception.new + tokens.each do |tok| + begin + validate_request(tok, answer, env.request, HMAC_KEY, locale) + found_valid_captcha = true + rescue ex + error_exception = ex + end + end + + if !found_valid_captcha + return error_json(500, error_exception) + end + else + # send captcha + captcha = Invidious::User::Captcha.generate_text(HMAC_KEY, ":api_register") + # Fix token formatting + formatted_tokens : Array(JSON::Any) = Array(JSON::Any).new + captcha["tokens"].each do |tok| + formatted_tokens << JSON.parse(tok) + end + captcha_request = JSON.build do |json| + json.object do + json.field "question", captcha["question"] + json.field "tokens", formatted_tokens + end + end + return captcha_request + end + end + # create user + sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) + user, sid = create_user(sid, username, password) + Invidious::Database::Users.insert(user) + Invidious::Database::SessionIDs.insert(sid, username) + # send user info + if token = Invidious::Database::SessionIDs.select_one(sid: sid) + response = JSON.build do |json| + json.object do + json.field "session", token[:session] + json.field "issued", token[:issued].to_unix + end + end + return response + else + return error_json(500, "Token not found") + end + else + return error_json(401, "No credentials") + end + else + return error_json(400, "Registration has been disabled by administrator") + end + end + + def self.api_login(env) + env.response.content_type = "application/json" + if !CONFIG.login_enabled + return error_json(400, "Login has been disabled by administrator") + else + creds = CredentialsLogin.from_json(env.request.body || "{}") + user = Invidious::Database::Users.select(email: creds.username) + old_sid = creds.token + if user + if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(creds.password.byte_slice(0, 55)) + sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) + Invidious::Database::SessionIDs.insert(sid: sid, email: creds.username) + if old_sid != "" + Invidious::Database::SessionIDs.delete(sid: old_sid) + end + if token = Invidious::Database::SessionIDs.select_one(sid: sid) + response = JSON.build do |json| + json.object do + json.field "session", token[:session] + json.field "issued", token[:issued].to_unix + end + end + return response + else + return error_json(500, "Token not found") + end + else + return error_json(401, "Wrong username or password") + end + else + return error_json(400, "Not registered") + end + end + end + + def self.api_signout(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + sid = env.request.cookies["SID"].value + Invidious::Database::SessionIDs.delete(sid: sid) + env.response.status_code = 200 + end +end + +struct CaptchaResponse + include JSON::Serializable + include YAML::Serializable + + property username : String + property password : String + property answer : String + property tokens : Array(JSON::Any) +end + +struct Credentials + include JSON::Serializable + include YAML::Serializable + + property username : String + property password : String +end + +struct CredentialsLogin + include JSON::Serializable + include YAML::Serializable + + property username : String + property password : String + property token : String +end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index daaf4d88..70666f89 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -244,6 +244,11 @@ module Invidious::Routing get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions get "/api/v1/hashtag/:hashtag", {{namespace}}::Search, :hashtag + # Authentication + post "/api/v1/api_register", {{namespace}}::Authentication, :api_register + post "/api/v1/api_login", {{namespace}}::Authentication, :api_login + post "/api/v1/api_signout", {{namespace}}::Authentication, :api_signout + # Authenticated # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr diff --git a/src/invidious/user/captcha.cr b/src/invidious/user/captcha.cr index 8a0f67e5..b1f5c5d6 100644 --- a/src/invidious/user/captcha.cr +++ b/src/invidious/user/captcha.cr @@ -61,12 +61,12 @@ struct Invidious::User } end - def generate_text(key) + def generate_text(key, scope = ":login") response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body) response = JSON.parse(response) tokens = response["a"].as_a.map do |answer| - generate_response(answer.as_s, {":login"}, key, use_nonce: true) + generate_response(answer.as_s, {scope}, key, use_nonce: true) end return {