diff --git a/config/config.example.yml b/config/config.example.yml index a3a2eeb7..3b3ad2cd 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -345,6 +345,40 @@ http_proxy: ## #enable_user_notifications: true +## +## List of Enabled Authentication Backend +## If not provided falls back to default +## +## Supported Values: +## - invidious +## - oauth +## - ldap (Not implemented !) +## - saml (Not implemented !) +## +## Default: ["invidious","oauth"] +## +# auth_type: ["oauth"] + +## +## OAuth Configuration +## +## Notes: +## - Supports multiple OAuth backends +## - Requires external_port and domain to be configured +## +## Default: [] +## +# oauth: +# example: +# host: oauth.example.net +# field : email +# auth_uri: /oauth/authorize/ +# token_uri: /oauth/token/ +# info_uri: https://api.example.net/oauth/userinfo/ +# client_id: CLIENT_ID +# client_secret: CLIENT_SECRET + + # ----------------------------- # Background jobs # ----------------------------- diff --git a/src/invidious/config.cr b/src/invidious/config.cr index c4ca622f..88d89ec1 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -8,6 +8,18 @@ struct DBConfig property dbname : String end +struct OAuthConfig + include YAML::Serializable + + property host : String + property field : String = "email" + property auth_uri : String + property token_uri : String + property info_uri : String + property client_id : String + property client_secret : String +end + struct ConfigPreferences include YAML::Serializable @@ -151,6 +163,10 @@ class Config # poToken for passing bot attestation property po_token : String? = nil + property auth_type : Array(String) = ["invidious", "oauth"] + property auth_enforce_source : Bool = true + property oauth = {} of String => OAuthConfig + # Saved cookies in "name1=value1; name2=value2..." format @[YAML::Field(converter: Preferences::StringToCookies)] property cookies : HTTP::Cookies = HTTP::Cookies.new @@ -173,6 +189,14 @@ class Config end end + def auth_oauth_enabled? + return (@auth_type.find(&.== "oauth") && @oauth.size > 0) + end + + def auth_internal_enabled? + return (@auth_type.find(&.== "invidious")) + end + def self.load # Load config from file or YAML string env var env_config_file = "INVIDIOUS_CONFIG_FILE" diff --git a/src/invidious/helpers/oauth.cr b/src/invidious/helpers/oauth.cr new file mode 100644 index 00000000..2ef6c91c --- /dev/null +++ b/src/invidious/helpers/oauth.cr @@ -0,0 +1,53 @@ +require "oauth2" + +module Invidious::OAuthHelper + extend self + + def get_provider(key) + if provider = CONFIG.oauth[key]? + provider + else + raise Exception.new("Invalid OAuth Endpoint: " + key) + end + end + + def make_client(key) + if HOST_URL == "" + raise Exception.new("Missing domain and port configuration") + end + provider = get_provider(key) + redirect_uri = "#{HOST_URL}/login/oauth/#{key}" + OAuth2::Client.new( + provider.host, + provider.client_id, + provider.client_secret, + authorize_uri: provider.auth_uri, + token_uri: provider.token_uri, + redirect_uri: redirect_uri + ) + end + + def get_uri_host_pair(host, url) + if (url.starts_with?(/https*\:\/\//)) + uri = URI.parse url + [uri.host || host, uri.path || "/"] + else + [host, url] + end + end + + def get_info(key, token) + provider = self.get_provider(key) + uri_host_pair = self.get_uri_host_pair(provider.host, provider.info_uri) + client = HTTP::Client.new(uri_host_pair[0], tls: true) + token.authenticate(client) + response = client.get uri_host_pair[1] + client.close + response.body + end + + def info_field(key, token) + info = JSON.parse(self.get_info(key, token)) + info[self.get_provider(key).field].as_s? + end +end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index d0f7ac22..6ca0e9f2 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -3,11 +3,10 @@ module Invidious::Routes::Login def self.login_page(env) locale = env.get("preferences").as(Preferences).locale + referer = get_referer(env, "/feed/subscriptions") user = env.get? "user" - referer = get_referer(env, "/feed/subscriptions") - return env.redirect referer if user if !CONFIG.login_enabled @@ -19,7 +18,13 @@ module Invidious::Routes::Login captcha = nil account_type = env.params.query["type"]? - account_type ||= "invidious" + account_type ||= "" + + if CONFIG.auth_type.size == 0 + return error_template(401, "No authentication backend enabled.") + elsif CONFIG.auth_type.find(&.== account_type).nil? && CONFIG.auth_type.size == 1 + account_type = CONFIG.auth_type[0] + end captcha_type = env.params.query["captcha"]? captcha_type ||= "image" @@ -27,9 +32,38 @@ module Invidious::Routes::Login templated "user/login" end + def self.login_oauth(env) + locale = env.get("preferences").as(Preferences).locale + referer = get_referer(env, "/feed/subscriptions") + + authorization_code = env.params.query["code"]? + provider_k = env.params.url["provider"] + + if authorization_code.nil? + return error_template(403, "Missing Authorization Code") + end + begin + token = OAuthHelper.make_client(provider_k).get_access_token_using_authorization_code(authorization_code) + + if email = OAuthHelper.info_field(provider_k, token) + if user = Invidious::Database::Users.select(email: email) + if CONFIG.auth_enforce_source && user.password != ("oauth:" + provider_k) + return error_template(401, "Wrong provider") + else + user_flow_existing(env, email) + end + else + user_flow_new(env, email, nil, "oauth:" + provider_k) + end + end + rescue ex + return error_template(500, "Internal Error") + end + env.redirect referer + end + def self.login(env) locale = env.get("preferences").as(Preferences).locale - referer = get_referer(env, "/feed/subscriptions") if !CONFIG.login_enabled @@ -41,9 +75,22 @@ module Invidious::Routes::Login password = env.params.body["password"]? account_type = env.params.query["type"]? - account_type ||= "invidious" + account_type ||= "" + + if CONFIG.auth_type.size == 0 + return error_template(401, "No authentication backend enabled.") + elsif CONFIG.auth_type.find(&.== account_type).nil? && CONFIG.auth_type.size == 1 + account_type = CONFIG.auth_type[0] + end case account_type + when "oauth" + provider_k = env.params.body["provider"] + env.redirect OAuthHelper.make_client(provider_k).get_authorize_uri("openid email profile") + when "saml" + return error_template(501, "Not implemented") + when "ldap" + return error_template(501, "Not implemented") when "invidious" if email.nil? || email.empty? return error_template(401, "User ID is a required field") @@ -53,24 +100,14 @@ module Invidious::Routes::Login return error_template(401, "Password is a required field") end - user = Invidious::Database::Users.select(email: email) - - if user - if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) - sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - Invidious::Database::SessionIDs.insert(sid, email) - - env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) + if user = Invidious::Database::Users.select(email: email) + if user.password.not_nil!.starts_with? "oauth" + return error_template(401, "Wrong provider") + elsif Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) + user_flow_existing(env, email) 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.") @@ -147,32 +184,7 @@ module Invidious::Routes::Login end end end - - sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - user, sid = create_user(sid, email, password) - - if language_header = env.request.headers["Accept-Language"]? - if language = ANG.language_negotiator.best(language_header, LOCALES.keys) - user.preferences.locale = language.header - end - end - - Invidious::Database::Users.insert(user) - Invidious::Database::SessionIDs.insert(sid, email) - - view_name = "subscriptions_#{sha256(user.email)}" - PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - - env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) - - if env.request.cookies["PREFS"]? - user.preferences = env.get("preferences").as(Preferences) - Invidious::Database::Users.update_preferences(user) - - cookie = env.request.cookies["PREFS"] - cookie.expires = Time.utc(1990, 1, 1) - env.response.cookies << cookie - end + user_flow_new(env, email, password, "internal") end env.redirect referer @@ -211,4 +223,49 @@ module Invidious::Routes::Login env.redirect referer end + + def self.user_flow_existing(env, email) + sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) + Invidious::Database::SessionIDs.insert(sid, email) + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) + + # 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 + end + + def self.user_flow_new(env, email, password, provider) + sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) + if provider == "internal" + user, sid = create_internal_user(sid, email, password) + else + user, sid = create_user(sid, email, provider) + end + + if language_header = env.request.headers["Accept-Language"]? + if language = ANG.language_negotiator.best(language_header, LOCALES.keys) + user.preferences.locale = language.header + end + end + + Invidious::Database::Users.insert(user) + Invidious::Database::SessionIDs.insert(sid, email) + + view_name = "subscriptions_#{sha256(user.email)}" + PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") + + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) + + if env.request.cookies["PREFS"]? + user.preferences = env.get("preferences").as(Preferences) + Invidious::Database::Users.update_preferences(user) + + cookie = env.request.cookies["PREFS"] + cookie.expires = Time.utc(1990, 1, 1) + env.response.cookies << cookie + end + end end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 9009062f..61a2d383 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -55,6 +55,7 @@ module Invidious::Routing def register_user_routes # User login/out get "/login", Routes::Login, :login_page + get "/login/oauth/:provider", Routes::Login, :login_oauth post "/login", Routes::Login, :login post "/signout", Routes::Login, :signout diff --git a/src/invidious/user/cookies.cr b/src/invidious/user/cookies.cr index 654efc15..4a81e6d3 100644 --- a/src/invidious/user/cookies.cr +++ b/src/invidious/user/cookies.cr @@ -14,6 +14,7 @@ struct Invidious::User return HTTP::Cookie.new( name: "SID", domain: domain, + path: "/", value: sid, expires: Time.utc + 2.years, secure: SECURE, @@ -28,6 +29,7 @@ struct Invidious::User return HTTP::Cookie.new( name: "PREFS", domain: domain, + path: "/", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, secure: SECURE, diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 65566d20..5d45ce6e 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -4,7 +4,6 @@ require "crypto/bcrypt/password" MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" } def create_user(sid, email, password) - password = Crypto::Bcrypt::Password.create(password, cost: 10) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) user = Invidious::User.new({ @@ -13,7 +12,7 @@ def create_user(sid, email, password) subscriptions: [] of String, email: email, preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple), - password: password.to_s, + password: password, token: token, watched: [] of String, feed_needs_update: true, @@ -22,6 +21,11 @@ def create_user(sid, email, password) return user, sid end +def create_internal_user(sid, email, password) + password = Crypto::Bcrypt::Password.create(password.not_nil!, cost: 10) + create_user(sid, email, password.to_s) +end + def get_subscription_feed(user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit diff --git a/src/invidious/views/user/login.ecr b/src/invidious/views/user/login.ecr index 2b03d280..b44f6066 100644 --- a/src/invidious/views/user/login.ecr +++ b/src/invidious/views/user/login.ecr @@ -7,7 +7,18 @@
<% case account_type when %> - <% else # "invidious" %> + <% when "oauth" %> +
+
+ + +
+
+ <% when "invidious" %>
<% if email %> @@ -70,6 +81,14 @@ <% end %>
+ <% else %> + <% if CONFIG.auth_internal_enabled? %> + Internal + <% end %> + <% if CONFIG.auth_oauth_enabled? %> + OAuth + <% end %> + <% end %>