Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/sql/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS public.users
token text,
watched text[],
feed_needs_update boolean,
totp_secret VARCHAR(128)
CONSTRAINT users_email_key UNIQUE (email)
);

Expand Down
15 changes: 13 additions & 2 deletions locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -484,5 +484,16 @@
"channel_tab_releases_label": "Releases",
"channel_tab_playlists_label": "Playlists",
"channel_tab_community_label": "Community",
"channel_tab_channels_label": "Channels"
}
"channel_tab_channels_label": "Channels",
"setup_totp_form_header": "Setup two-factor authentication (TOTP)",
"setup_totp_instructions_download_auth": "Install an authenticator app (or anything that supports TOTP) on your device",
"setup_totp_instructions_enter_code": "Enter the following <strong>secret</strong> code:",
"setup_totp_instructions_validate_code": "Enter the 6 digit number on your screen. Be sure to do it under thirty seconds!",
"setup_totp_submit_button": "Setup TOTP",
"general_totp_empty_field": "The TOTP code is a required field",
"general_totp_invalid_code": "The TOTP code entered is invalid",
"general_totp_enter_code_field": "6 digit number",
"general_totp_enter_code_header": "Two-factor authentication",
"general_totp_verify_button": "Verify",
"remove_totp_header": "Remove two-factor authentication (TOTP)",
"remove_totp_confirm_message": "Are you sure you would like to remove two-factor-authentication?"}
21 changes: 15 additions & 6 deletions shard.lock
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
version: 2.0
shards:
ameba:
git: https://github.com/crystal-ameba/ameba.git
version: 0.14.4

athena-negotiation:
git: https://github.com/athena-framework/negotiation.git
version: 0.1.1
version: 0.1.3

backtracer:
git: https://github.com/sija/backtracer.cr.git
version: 1.2.1
version: 1.2.2

base32:
git: https://github.com/philnash/base32.git
version: 0.1.1+git.commit.0a21c1d90731fdefcb3f0db4913f49d3d25350ac

crotp:
git: https://github.com/philnash/crotp.git
version: 1.0.0

db:
git: https://github.com/crystal-lang/crystal-db.git
Expand Down Expand Up @@ -42,12 +54,9 @@ shards:

spectator:
git: https://github.com/icy-arctic-fox/spectator.git
version: 0.10.4
version: 0.10.6

sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.18.0

ameba:
git: https://github.com/crystal-ameba/ameba.git
version: 0.14.3
3 changes: 3 additions & 0 deletions shard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ dependencies:
athena-negotiation:
github: athena-framework/negotiation
version: ~> 0.1.1
crotp:
github: philnash/crotp
version: ~> 1.0.0

development_dependencies:
spectator:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Invidious::Database::Migrations
class AddTotpSecretToUsersTable < Migration
version 11

def up(conn : DB::Connection)
conn.exec <<-SQL
ALTER TABLE users ADD COLUMN totp_secret VARCHAR(128)
SQL
end
end
end
11 changes: 11 additions & 0 deletions src/invidious/helpers/utils.cr
Original file line number Diff line number Diff line change
Expand Up @@ -445,3 +445,14 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
end
return text
end

# Templates the 2fa validator page.
#
# Requires the env, user, sid and locale variables for
# generating a csrf_token and the required variables for the view.
def call_totp_validator(env, user, sid, locale)
referer = URI.decode_www_form(env.get?("current_page").to_s)
csrf_token = generate_response(sid, {":2fa/validate"}, HMAC_KEY)
email, password = {user.email, nil}
return templated "user/validate_2fa"
end
213 changes: 211 additions & 2 deletions src/invidious/routes/account.cr
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{% skip_file if flag?(:api_only) %}

require "crotp"

module Invidious::Routes::Account
extend self

Expand All @@ -21,6 +23,11 @@ module Invidious::Routes::Account

user = user.as(User)
sid = sid.as(String)

if user.totp_secret && env.request.cookies["2faVerified"]?.try &.value != "1" || nil
return call_totp_validator(env, user, sid, locale)
end

csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY)

templated "user/change_password"
Expand Down Expand Up @@ -96,6 +103,11 @@ module Invidious::Routes::Account

user = user.as(User)
sid = sid.as(String)

if user.totp_secret && env.request.cookies["2faVerified"]?.try &.value != "1" || nil
return call_totp_validator(env, user, sid, locale)
end

csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY)

templated "user/delete_account"
Expand Down Expand Up @@ -195,14 +207,20 @@ module Invidious::Routes::Account

user = env.get? "user"
sid = env.get? "sid"

user = user.as(User)
sid = sid.as(String)

if user.totp_secret && env.request.cookies["2faVerified"]?.try &.value != "1" || nil
return call_totp_validator(env, user, sid, locale)
end

referer = get_referer(env)

if !user
return env.redirect "/login?referer=#{URI.encode_path_segment(env.request.resource)}"
end

user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY)

scopes = env.params.query["scopes"]?.try &.split(",")
Expand Down Expand Up @@ -351,4 +369,195 @@ module Invidious::Routes::Account
return "{}"
end
end

# -------------------
# 2fa through OTP handling
# -------------------

# Templates the page to setup 2fa on an user account
def setup_2fa_page(env)
locale = env.get("preferences").as(Preferences).locale

user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env, unroll: false)

if !user
return env.redirect referer
end

user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":2fa/setup"}, HMAC_KEY)

db_secret = Random::Secure.random_bytes(16).hexstring
totp = CrOTP::TOTP.new(db_secret)
user_secret = totp.base32_secret

return templated "user/setup_2fa"
end

# Handles requests to setup 2fa on an user account
def setup_2fa(env)
locale = env.get("preferences").as(Preferences).locale

user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env, unroll: false)

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, locale)
rescue ex
return error_template(400, ex)
end

totp_code = env.params.body["totp_code"]?
db_secret = env.params.body["db_secret"] # Must exist
if !totp_code
return error_template(401, translate(locale, "general-totp-empty-field"))
end

totp_instance = CrOTP::TOTP.new(db_secret)
if !totp_instance.verify(totp_code)
return error_template(401, translate(locale, "general-totp-invalid-code"))
end

PG_DB.exec("UPDATE users SET totp_secret = $1 WHERE email = $2", db_secret.to_s, user.email)
env.redirect referer
end

# Handles requests to validate a TOTP code on an user account
def validate_2fa(env)
locale = env.get("preferences").as(Preferences).locale
referer = get_referer(env, unroll: false)

email = env.params.body["email"]?.try &.downcase.byte_slice(0, 254)
password = env.params.body["password"]?
totp_code = env.params.body["totp_code"]?
# This endpoint is only called when the user has a totp_secret.
user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User).not_nil!

if !totp_code
return error_template(401, translate(locale, "general-totp-empty-field"))
end

totp_instance = CrOTP::TOTP.new(user.totp_secret.not_nil!)
if !totp_instance.verify(totp_code)
return error_template(401, translate(locale, "general-totp-invalid-code"))
end

if Kemal.config.ssl || CONFIG.https_only
secure = true
else
secure = false
end

#
# The validate_2fa method is used in two cases:
# 1. To authenticate the user when logging in
# 2. To verify that the user wishes to proceed with a dangerous action.
#
# As we've verified that the totp given is correct we can now proceed with
# authenticating and/or redirecting the user back to where they came from
#

logging_in = (email && password)

if logging_in
# Authenticate the user. The rest follows the code in login.cr
if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.not_nil!.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 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, path: "/")
else
env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
secure: secure, http_only: true, path: "/")
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

env.redirect referer
else
token = env.params.body["csrf_token"]

begin
validate_request(token, env.get?("sid").as(String), env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
end

if CONFIG.domain
env.response.cookies["2faVerified"] = HTTP::Cookie.new(name: "2faVerified", domain: "#{CONFIG.domain}", value: "1", expires: Time.utc + 5.minutes, secure: secure, http_only: true, path: "/")
else
env.response.cookies["2faVerified"] = HTTP::Cookie.new(name: "2faVerified", value: "1", expires: Time.utc + 5.minutes, secure: secure, http_only: true, path: "/")
end
end

env.redirect referer
end

# Templates the page to remove 2fa on an user account
def remove_2fa_page(env)
locale = env.get("preferences").as(Preferences).locale

user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env, unroll: false)

if !user || user.is_a? User && !user.totp_secret
return env.redirect referer
end

user = user.as(User)
sid = sid.as(String)
csrf_token = generate_response(sid, {":2fa/remove"}, HMAC_KEY)

return templated "user/remove_2fa"
end

# Handles requests to remove 2fa on an user account
def remove_2fa(env)
locale = env.get("preferences").as(Preferences).locale

user = env.get? "user"
sid = env.get? "sid"
referer = get_referer(env, unroll: false)

if !user || user.is_a? User && !user.totp_secret
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, locale)
rescue ex
return error_template(400, ex)
end

PG_DB.exec("UPDATE users SET totp_secret = $1 WHERE email = $2", nil, user.email)
env.redirect referer
end
end
6 changes: 6 additions & 0 deletions src/invidious/routes/login.cr
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ module Invidious::Routes::Login

if user
if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
# If the password is correct then we'll go ahead and begin 2fa if applicable
if user.totp_secret
csrf_token = nil # setting this to nil for compatibility reasons.
return templated "user/validate_2fa"
end

sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
Invidious::Database::SessionIDs.insert(sid, email)

Expand Down
7 changes: 7 additions & 0 deletions src/invidious/routing.cr
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ module Invidious::Routing
post "/token_ajax", Routes::Account, :token_ajax
post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription
get "/subscription_manager", Routes::Subscriptions, :subscription_manager

# 2fa routes
Invidious::Routing.get "/2fa/setup", Routes::Account, :setup_2fa_page
Invidious::Routing.post "/2fa/setup", Routes::Account, :setup_2fa
Invidious::Routing.get "/2fa/remove", Routes::Account, :remove_2fa_page
Invidious::Routing.post "/2fa/remove", Routes::Account, :remove_2fa
Invidious::Routing.post "/2fa/validate", Routes::Account, :validate_2fa
end

def register_iv_playlist_routes
Expand Down
1 change: 1 addition & 0 deletions src/invidious/user/user.cr
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct Invidious::User
property notifications : Array(String)
property subscriptions : Array(String)
property email : String
property totp_secret : String?

@[DB::Field(converter: Invidious::User::PreferencesConverter)]
property preferences : Preferences
Expand Down
1 change: 1 addition & 0 deletions src/invidious/users.cr
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def create_user(sid, email, password)
token: token,
watched: [] of String,
feed_needs_update: true,
totp_secret: nil,
})

return user, sid
Expand Down
Loading