mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-06 17:12:45 -05:00
Add trusted-header authentication (Authelia SSO)
New config block trusted_header_auth. When enabled, before_all resolves the session from the proxy-asserted header (default Remote-User) instead of only the SID cookie: - The header is honored only when the direct TCP peer is in trusted_proxies (literal IPs, IPv4-mapped IPv6 normalized). X-Forwarded-For is never consulted. Duplicated headers reject. - /api/ is excluded: token clients (Yattee) are unaffected. - Unknown users are provisioned like manual registration, with the subscriptions materialized view and a random bcrypt password. Both statements tolerate concurrent provisioning. - A session cookie is set and reused; a cookie that belongs to a different user is dropped (identity-switch guard). - Boot fails closed: enabled without valid trusted_proxies exits. - Optional logout_url replaces the local sign-out form so logout ends the proxy session, not just the Invidious one. The reverse proxy MUST strip the header on routes that bypass its authentication (see config.example.yml warning). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
cce26864ad
commit
8e5a9e079e
@ -370,6 +370,27 @@ https_only: false
|
|||||||
##
|
##
|
||||||
#login_enabled: true
|
#login_enabled: true
|
||||||
|
|
||||||
|
##
|
||||||
|
## Trusted-header authentication (ArikTube extension).
|
||||||
|
##
|
||||||
|
## An authenticating reverse proxy (for example Authelia) asserts the
|
||||||
|
## user name in a request header. The header is only honored when the
|
||||||
|
## direct TCP peer is listed in trusted_proxies (literal IP addresses,
|
||||||
|
## no CIDR ranges). Accounts are created on first sight. /api/ routes
|
||||||
|
## are excluded — API clients keep using tokens.
|
||||||
|
##
|
||||||
|
## WARNING: the reverse proxy MUST strip this header from client
|
||||||
|
## requests on every route that bypasses its authentication.
|
||||||
|
##
|
||||||
|
## Default: disabled
|
||||||
|
##
|
||||||
|
#trusted_header_auth:
|
||||||
|
# enabled: true
|
||||||
|
# header: Remote-User
|
||||||
|
# trusted_proxies:
|
||||||
|
# - 192.168.1.101
|
||||||
|
# logout_url: "https://auth.example.com/logout"
|
||||||
|
|
||||||
##
|
##
|
||||||
## Enable/Disable the captcha challenge on the login page.
|
## Enable/Disable the captcha challenge on the login page.
|
||||||
##
|
##
|
||||||
|
|||||||
@ -80,6 +80,21 @@ struct HTTPProxyConfig
|
|||||||
property port : Int32
|
property port : Int32
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Trusted-header authentication (ArikTube extension). An authenticating
|
||||||
|
# reverse proxy (Authelia) asserts the user name in a request header.
|
||||||
|
struct TrustedHeaderAuthConfig
|
||||||
|
include YAML::Serializable
|
||||||
|
|
||||||
|
property enabled : Bool = false
|
||||||
|
# Request header that carries the authenticated user name
|
||||||
|
property header : String = "Remote-User"
|
||||||
|
# Literal peer IPs allowed to assert the header. The TCP peer address is
|
||||||
|
# compared, never X-Forwarded-For. CIDR ranges are not supported.
|
||||||
|
property trusted_proxies : Array(String) = [] of String
|
||||||
|
# Optional target for the sign-out link while header auth is active
|
||||||
|
property logout_url : String? = nil
|
||||||
|
end
|
||||||
|
|
||||||
class Config
|
class Config
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
@ -135,6 +150,8 @@ class Config
|
|||||||
property captcha_enabled : Bool = true
|
property captcha_enabled : Bool = true
|
||||||
property login_enabled : Bool = true
|
property login_enabled : Bool = true
|
||||||
property registration_enabled : Bool = true
|
property registration_enabled : Bool = true
|
||||||
|
# Trusted-header authentication (ArikTube extension)
|
||||||
|
property trusted_header_auth : TrustedHeaderAuthConfig = TrustedHeaderAuthConfig.from_yaml("")
|
||||||
property statistics_enabled : Bool = false
|
property statistics_enabled : Bool = false
|
||||||
property admins : Array(String) = [] of String
|
property admins : Array(String) = [] of String
|
||||||
property external_port : Int32? = nil
|
property external_port : Int32? = nil
|
||||||
@ -325,6 +342,24 @@ class Config
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Trusted-header auth (ArikTube extension): fail closed on misconfiguration
|
||||||
|
if config.trusted_header_auth.enabled
|
||||||
|
if config.trusted_header_auth.header.strip.empty?
|
||||||
|
puts "Config: trusted_header_auth.header can't be empty"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
if config.trusted_header_auth.trusted_proxies.empty?
|
||||||
|
puts "Config: trusted_header_auth needs at least one trusted_proxies entry"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
config.trusted_header_auth.trusted_proxies.each do |address|
|
||||||
|
if address.includes?('/') || !Socket::IPAddress.valid?(address)
|
||||||
|
puts "Config: trusted_header_auth.trusted_proxies takes literal IP addresses only (got '#{address}')"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Check if the socket configuration is valid
|
# Check if the socket configuration is valid
|
||||||
if sb = config.socket_binding
|
if sb = config.socket_binding
|
||||||
if sb.path.ends_with?("/") || File.directory?(sb.path)
|
if sb.path.ends_with?("/") || File.directory?(sb.path)
|
||||||
|
|||||||
@ -80,13 +80,21 @@ module Invidious::Routes::BeforeAll
|
|||||||
"/companion/",
|
"/companion/",
|
||||||
}.any? { |r| env.request.resource.starts_with? r }
|
}.any? { |r| env.request.resource.starts_with? r }
|
||||||
|
|
||||||
|
sid = nil
|
||||||
if env.request.cookies.has_key? "SID"
|
if env.request.cookies.has_key? "SID"
|
||||||
sid = env.request.cookies["SID"].value
|
sid = env.request.cookies["SID"].value
|
||||||
|
|
||||||
if sid.starts_with? "v1:"
|
if sid.starts_with? "v1:"
|
||||||
raise "Cannot use token as SID"
|
raise "Cannot use token as SID"
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ArikTube: open or provision the session asserted by the reverse proxy
|
||||||
|
if asserted_email = Invidious::TrustedHeaderAuth.asserted_email(env)
|
||||||
|
sid = Invidious::TrustedHeaderAuth.ensure_session(env, sid, asserted_email)
|
||||||
|
end
|
||||||
|
|
||||||
|
if sid
|
||||||
if email = Database::SessionIDs.select_email(sid)
|
if email = Database::SessionIDs.select_email(sid)
|
||||||
user = Database::Users.select!(email: email)
|
user = Database::Users.select!(email: email)
|
||||||
csrf_token = generate_response(sid, {
|
csrf_token = generate_response(sid, {
|
||||||
|
|||||||
89
src/invidious/trusted_header_auth.cr
Normal file
89
src/invidious/trusted_header_auth.cr
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Trusted-header authentication (ArikTube extension).
|
||||||
|
#
|
||||||
|
# An authenticating reverse proxy (Authelia behind Traefik) asserts the user
|
||||||
|
# name in a request header. The header is only honored when the TCP peer is
|
||||||
|
# listed in `trusted_proxies` — never based on X-Forwarded-For. The account
|
||||||
|
# is provisioned on first sight, so a browser with a proxy session never
|
||||||
|
# sees the Invidious login form.
|
||||||
|
#
|
||||||
|
# The reverse proxy MUST strip the configured header from client requests on
|
||||||
|
# every route that bypasses its authentication, or clients can impersonate
|
||||||
|
# users through those routes.
|
||||||
|
module Invidious::TrustedHeaderAuth
|
||||||
|
extend self
|
||||||
|
|
||||||
|
# "::ffff:192.168.1.101" and "192.168.1.101" are the same peer
|
||||||
|
private def normalize_ip(address : String) : String
|
||||||
|
address.lchop("::ffff:")
|
||||||
|
end
|
||||||
|
|
||||||
|
private def trusted_peer?(env) : Bool
|
||||||
|
remote = env.request.remote_address.as?(Socket::IPAddress)
|
||||||
|
return false unless remote
|
||||||
|
|
||||||
|
peer = normalize_ip(remote.address)
|
||||||
|
CONFIG.trusted_header_auth.trusted_proxies.any? { |address| normalize_ip(address) == peer }
|
||||||
|
end
|
||||||
|
|
||||||
|
# The user name asserted by the proxy, or nil when absent or untrusted.
|
||||||
|
def asserted_email(env) : String?
|
||||||
|
config = CONFIG.trusted_header_auth
|
||||||
|
return nil unless config.enabled
|
||||||
|
|
||||||
|
# API clients (Yattee, bots) authenticate with tokens only
|
||||||
|
return nil if env.request.path.starts_with?("/api/")
|
||||||
|
|
||||||
|
values = env.request.headers.get?(config.header)
|
||||||
|
return nil unless values
|
||||||
|
# A duplicated header is an attack indicator: reject the request
|
||||||
|
return nil if values.size != 1
|
||||||
|
|
||||||
|
email = values[0].strip.downcase.byte_slice(0, 254)
|
||||||
|
return nil if email.empty?
|
||||||
|
return nil unless trusted_peer?(env)
|
||||||
|
|
||||||
|
email
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return a session id for `email`. Reuses `sid` when that session already
|
||||||
|
# belongs to the user; otherwise provisions account + session and sets the
|
||||||
|
# SID cookie, mirroring the manual login flow (routes/login.cr).
|
||||||
|
def ensure_session(env, sid : String?, email : String) : String
|
||||||
|
if sid
|
||||||
|
session_email = Invidious::Database::SessionIDs.select_email(sid)
|
||||||
|
return sid if session_email == email
|
||||||
|
# Identity switch: the cookie belongs to somebody else. Drop it.
|
||||||
|
Invidious::Database::SessionIDs.delete(sid: sid) if session_email
|
||||||
|
end
|
||||||
|
|
||||||
|
if !Invidious::Database::Users.select(email: email)
|
||||||
|
provision_user(email)
|
||||||
|
end
|
||||||
|
|
||||||
|
new_sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
||||||
|
Invidious::Database::SessionIDs.insert(new_sid, email, handle_conflicts: true)
|
||||||
|
|
||||||
|
host = env.get("header_x-forwarded-host")
|
||||||
|
if alt = CONFIG.alternative_domains.index(host)
|
||||||
|
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], new_sid)
|
||||||
|
else
|
||||||
|
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, new_sid)
|
||||||
|
end
|
||||||
|
|
||||||
|
new_sid
|
||||||
|
end
|
||||||
|
|
||||||
|
# Same steps as manual registration (routes/login.cr), with a random
|
||||||
|
# password nobody knows. The materialized view is required — without it
|
||||||
|
# the subscriptions feed raises. Both statements tolerate a concurrent
|
||||||
|
# provision of the same user (parallel first-page requests).
|
||||||
|
private def provision_user(email : String)
|
||||||
|
random_password = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
||||||
|
user, _ = create_user("", email, random_password)
|
||||||
|
|
||||||
|
Invidious::Database::Users.insert(user, update_on_conflict: true)
|
||||||
|
|
||||||
|
view_name = "subscriptions_#{sha256(user.email)}"
|
||||||
|
PG_DB.exec("CREATE MATERIALIZED VIEW IF NOT EXISTS #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -72,12 +72,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="pure-u-1-4">
|
<div class="pure-u-1-4">
|
||||||
<form action="/signout?referer=<%= env.get?("current_page") %>" method="post">
|
<% if CONFIG.trusted_header_auth.enabled && (logout_url = CONFIG.trusted_header_auth.logout_url) %>
|
||||||
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
<a class="pure-menu-heading" href="<%= HTML.escape(logout_url) %>">
|
||||||
<a class="pure-menu-heading" href="#">
|
<%= I18n.translate(locale, "Log out") %>
|
||||||
<input style="all:unset" type="submit" value="<%= I18n.translate(locale, "Log out") %>">
|
|
||||||
</a>
|
</a>
|
||||||
</form>
|
<% else %>
|
||||||
|
<form action="/signout?referer=<%= env.get?("current_page") %>" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||||
|
<a class="pure-menu-heading" href="#">
|
||||||
|
<input style="all:unset" type="submit" value="<%= I18n.translate(locale, "Log out") %>">
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="pure-u-1-3">
|
<div class="pure-u-1-3">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user