Skip to content

Commit

Permalink
Merge 1755d75 into 0c1f991
Browse files Browse the repository at this point in the history
  • Loading branch information
maxvt committed Oct 17, 2016
2 parents 0c1f991 + 1755d75 commit d386b53
Show file tree
Hide file tree
Showing 8 changed files with 499 additions and 14 deletions.
70 changes: 70 additions & 0 deletions README.md
Expand Up @@ -88,6 +88,76 @@ Alice: Lita, confirm 636f308
Lita: 636f308 is not a valid confirmation code. It may have expired. Please run the original command again.
```

### Two Factor Confirmation

If the basic confirmation is just a "sanity check" (are you sure you want to do this?), and a confirmation
requiring another group or another user is much more critical ("we want another human to approve this"), then
two factor confirmation (2FC) is somewhere in the middle ("we want to be really sure it's actually you") and also
takes some of the verification trust away from Slack. Two factor confirmation works by setting up a TOTP
(time-based one time password) for each user and verifying it.

When 2FC is used, a TOTP must be entered in addition to the confirmation code:

```
Alice: Lita, danger
Lita: This command requires confirmation. To confirm, send the command: Lita confirm 636f308 YOUR_ONE_TIME_PASSWORD
Alice: Opens Google Authenticator on her phone, looks up the current password
Alice: Lita, confirm 636f308 682645
Lita: Dangerous command executed!
```

The default behavior is not to require 2FC on any routes. This global setting is configurable:

``` ruby
config.handlers.confirmation.twofactor_default = :allow
```

There are three choices:

1. `block` - two factor confirmation is not used
2. `allow` - if the user is enrolled into 2FC, it is used; otherwise, regular confirmation is used.
3. `require` - 2FC is mandatory. If the user is not enrolled, they will be asked to enroll first.

The default can be overridden for any route:

``` ruby
route /danger/, :danger, command: true, confirmation: { twofactor: :allow }
```

To enroll, a user provides an e-mail address, and receives an e-mail with the secret for the TOTP algorithm
and a QR code in an image that contains the same settings in a format readable by many popular TOTP apps.

```
Alice: Lita, confirm 2fa enroll alice@corp.io
Lita: You are now enrolled into two factor confirmation. Check your email inbox for details.
...
Subject: Lita two-factor confirmation enrollment
Hi Alice,
You are now enrolled into two factor confirmation. Your secret code is yczja4wk5mjy4koe.
Use the code with any TOTP application such as Google Authenticator
to generate one-time passwords. Thank you for improving security!
Many applications (including Google Authenticator) can import the secret
from the QR code attached to this message.
```

Three configuration settings are used for configuring e-mail:

- `smtp_host`, default `localhost`
- `smtp_port`, default `25`
- `from_email`, the From address for the enrollment e-mail.

Lita administrators and members of the `confirmation_admin` group can remove
2FC registration from other users. There is an "insecure" mode, disabled by
default, that allows users to remove themselves from 2FC and to enroll multiple
times. This can be useful when evaluating whether the benefits of 2FC outweigh
the inconvenience of having to consult another device for the password, and while
migrating from plain confirmation to 2FC. The config value is `twofactor_secure`
and the default is `true`.

## License

[MIT](http://opensource.org/licenses/MIT)
55 changes: 49 additions & 6 deletions lib/lita/extensions/confirmation.rb
Expand Up @@ -17,23 +17,66 @@ def initialize(handler, message, robot, route)
@message = message
@robot = robot
@route = route
# As this is not a handler, we do not get an automatic @redis
@redis = Redis::Namespace.new('handlers:confirmation', redis: Lita.redis)
end

def call
if (options = route.extensions[:confirmation])
message.reply(
I18n.t(
"lita.extensions.confirmation.request",
prefix: @robot.alias ? @robot.alias : "#{@robot.mention_format(@robot.mention_name)} ",
code: UnconfirmedCommand.new(handler, message, robot, route, options).code
if twofactor_enabled_for_user?(message.user.id) && route_allows_twofactor?(options)
# 2FA route
cmd = UnconfirmedCommand.new(handler, message, robot, route, true, options)
message.reply(
I18n.t(
"lita.extensions.confirmation.twofactor_request",
prefix: @robot.alias ? @robot.alias : "#{@robot.mention_format(@robot.mention_name)} ",
code: cmd.code
)
)
)

elsif !twofactor_enabled_for_user?(message.user.id) && route_requires_twofactor?(options)
message.reply(I18n.t("lita.extensions.confirmation.requires_2fa"))

else
# 1FA route
cmd = UnconfirmedCommand.new(handler, message, robot, route, false, options)
message.reply(
I18n.t(
"lita.extensions.confirmation.request",
prefix: @robot.alias ? @robot.alias : "#{@robot.mention_format(@robot.mention_name)} ",
code: cmd.code
)
)
end

return false
end

true
end

def twofactor_enabled_for_user?(user_id)
!@redis.hget(user_id, 'totp').nil?
end

def parse_twofactor_option(options)
if options.is_a?(Hash) && options[:twofactor]
unless %i(block allow require).include? options[:twofactor]
raise "#{options[:twofactor]} is not a valid value for Confirmation's twofactor option"
end
options[:twofactor]
else
Lita.config.handlers.confirmation.twofactor_default
end
end

def route_allows_twofactor?(options)
%i(allow require).include? parse_twofactor_option(options)
end

def route_requires_twofactor?(options)
:require == parse_twofactor_option(options)
end
end

Lita.register_hook(:validate_route, Confirmation)
Expand Down
6 changes: 3 additions & 3 deletions lib/lita/extensions/confirmation/unconfirmed_command.rb
Expand Up @@ -4,7 +4,7 @@ module Lita
module Extensions
class Confirmation
class UnconfirmedCommand
attr_reader :allow_self, :code, :groups, :handler, :message, :robot, :route, :timer_thread
attr_reader :allow_self, :code, :groups, :handler, :message, :robot, :route, :timer_thread, :twofactor

class << self
def find(code)
Expand All @@ -20,14 +20,14 @@ def reset
end
end

def initialize(handler, message, robot, route, options)
def initialize(handler, message, robot, route, twofactor, options)
@handler = handler
@message = message
@robot = robot
@route = route
@twofactor = twofactor

@code = SecureRandom.hex(3)

self.class.confirmations[code] = self

process_options(options)
Expand Down
141 changes: 138 additions & 3 deletions lib/lita/handlers/confirmation.rb
@@ -1,24 +1,132 @@
require "net/smtp"
require "rotp"
require "rqrcode"

module Lita
module Handlers
class Confirmation < Handler
# block - never use 2fa; allow; require - only allow 2fa
config :twofactor_default, required: false, type: [:block, :allow, :require], default: :block

# If users can re-enroll or remove themselves, there is no true "second factor"
# protection as control of Slack account lets an attacker re-enroll, obtain a new TOTP secret,
# and generate OTP passwords. This can be set to false during a transition to 2FA.
config :twofactor_secure, required: false, type: [TrueClass, FalseClass], default: true

config :smtp_host, required: false, type: String, default: 'localhost'
config :smtp_port, required: false, type: Fixnum, default: 25
config :from_email, required: false, type: String, default: 'example@example.com'
config :smtp_login, required: false, type: String, default: nil
config :smtp_password, required: false, type: String, default: nil

route /^confirm\s+([a-f0-9]{6})$/i, :confirm, command: true, help: {
t("help.key") => t("help.value")
t("confirm_help.key") => t("confirm_help.value")
}

route /^confirm\s+([a-f0-9]{6})\s+([0-9]{6})$/i, :totp_confirm, command: true, help: {
t("confirm_totp_help.key") => t("confirm_totp_help.value")
}

route /^confirm\s+2fa\s+enroll\s+(.*)\s*$/i,
:enroll, command:true, help: { t("enroll_help.key") => t("enroll_help.value") }

route /^confirm\s+2fa\s+remove$/i, :remove_self, command: true, help: {
t("remove_help.key") => t("remove_help.value")
}

route /^confirm\s+2fa\s+remove\s+(\S+)$/i, :remove, command: true

def confirm(response)
code = response.matches[0][0]
command = Extensions::Confirmation::UnconfirmedCommand.find(code)

unless command
response.reply(t("invalid_code", code: code))
return
end

if command.twofactor
response.reply(t("totp_not_provided"))
else
call_command(command, code, response)
end
end

def totp_confirm(response)
code = response.matches[0][0]
command = Extensions::Confirmation::UnconfirmedCommand.find(code)

if command
unless command
response.reply(t("invalid_code", code: code))
return
end

totp_secret = redis.hget(response.user.id, "totp")
unless totp_secret
response.reply(t("totp_not_enrolled"))
return
end

totp = ROTP::TOTP.new(totp_secret)
# Attempting to 2FA verify a non-2FA confirmation is not a bad thing
if totp.verify(response.matches[0][1]) || !command.twofactor
call_command(command, code, response)
else
response.reply(t("invalid_code", code: code))
response.reply(t("totp_incorrect_otp"))
end
end

def enroll(response)
email = response.matches[0][0]
unless email =~ /\A[^@\s]+@([^@\s]+\.)+[^@\s]+\z/
response.reply(t("enroll_email_failed_validation"))
return
end

totp = ROTP::Base32.random_base32
unless redis.hget(response.user.id, "totp") && config.twofactor_secure
begin
send_enroll_email(response.user, email, totp)
redis.hset(response.user.id, "totp", totp)
response.reply(t("enrolled"))
rescue Exception => e
response.reply(t("email_error", error: e.message))
end
else
response.reply(t("must_remove_to_reenroll"))
end
end

def remove_self(response)
if config.twofactor_secure && !privileged_user?(response.user)
response.reply(t("remove_requires_admin"))
else
redis.hdel(response.user.id, 'totp')
response.reply(t("removed", user: "You"))
end
end

def remove(response)
user = Lita::User.find_by_mention_name(response.matches[0][0])

if !privileged_user?(response.user)
response.reply(t("remove_requires_admin"))
else
if user
redis.hdel(user.id, 'totp')
response.reply(t("removed", user: user.name))
else
response.reply(t("remove_no_such_user"))
end
end
end

private

def privileged_user?(user)
robot.auth.user_in_group?(user, :admin) || robot.auth.user_in_group?(user, :confirmation_admin)
end

def call_command(command, code, response)
case command.call(response.user)
when :other_user_required
Expand All @@ -29,6 +137,33 @@ def call_command(command, code, response)
)
end
end

def send_enroll_email(user, email, totp_secret)
totp = ROTP::TOTP.new(totp_secret)
provisioning_url = "otpauth://totp/OfficerURL:#{email}?secret=#{totp_secret}&issuer=OfficerURL"
qrcode = RQRCode::QRCode.new(provisioning_url)

message = <<MESSAGE_END
From: "#{robot.name}" <#{config.from_email}>
To: "#{user.name}" <#{email}>
MIME-Version: 1.0
Content-type: multipart/mixed; boundary=NEXTPART
#{t("enroll_email", robot_name: robot.name, user_name: user.name, secret: totp_secret )}
--NEXTPART
Content-Type: image/png; name="qrcode.png"
Content-Transfer-Encoding:base64
Content-Disposition: attachment; filename="qrcode.png"
#{[qrcode.as_png(size: 640).to_s].pack("m")}
MESSAGE_END

Net::SMTP.start(config.smtp_host, config.smtp_port) do |smtp|
smtp.authenticate(config.smtp_login, config.smtp_password) if config.smtp_login
smtp.send_message(message, config.from_email, email)
end
end
end

Lita.register_handler(Confirmation)
Expand Down
3 changes: 3 additions & 0 deletions lita-confirmation.gemspec
Expand Up @@ -15,10 +15,13 @@ Gem::Specification.new do |spec|
spec.require_paths = ["lib"]

spec.add_runtime_dependency "lita", ">= 4.6.1"
spec.add_runtime_dependency "rotp", "~> 2.0"
spec.add_runtime_dependency "rqrcode"

spec.add_development_dependency "bundler", "~> 1.3"
spec.add_development_dependency "rake"
spec.add_development_dependency "rspec", ">= 3.0.0"
spec.add_development_dependency "simplecov"
spec.add_development_dependency "coveralls"
spec.add_development_dependency "pry"
end

0 comments on commit d386b53

Please sign in to comment.