Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 2fa to Invidious in the form of TOTP #2254

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
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