Skip to content

Commit c449816

Browse files
authored
Merge pull request #967 from supercaracal/fix-auth-for-acl
Support AUTH command for ACL
2 parents 0e084ac + aecf934 commit c449816

13 files changed

+152
-27
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ To connect to a password protected Redis instance, use:
5454
redis = Redis.new(password: "mysecret")
5555
```
5656

57+
To connect a Redis instance using [ACL](https://redis.io/topics/acl), use:
58+
59+
```ruby
60+
redis = Redis.new(username: 'myname', password: 'mysecret')
61+
```
62+
5763
The Redis class exports methods that are named identical to the commands
5864
they execute. The arguments these methods accept are often identical to
5965
the arguments specified on the [Redis website][redis-commands]. For

lib/redis.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def self.current
3939
# @option options [String] :path path to server socket (overrides host and port)
4040
# @option options [Float] :timeout (5.0) timeout in seconds
4141
# @option options [Float] :connect_timeout (same as timeout) timeout for initial connect in seconds
42+
# @option options [String] :username Username to authenticate against server
4243
# @option options [String] :password Password to authenticate against server
4344
# @option options [Integer] :db (0) Database to select after initial connect
4445
# @option options [Symbol] :driver Driver to use, currently supported: `:ruby`, `:hiredis`, `:synchrony`
@@ -143,12 +144,13 @@ def _client
143144

144145
# Authenticate to the server.
145146
#
146-
# @param [String] password must match the password specified in the
147-
# `requirepass` directive in the configuration file
147+
# @param [Array<String>] args includes both username and password
148+
# or only password
148149
# @return [String] `OK`
149-
def auth(password)
150+
# @see https://redis.io/commands/auth AUTH command
151+
def auth(*args)
150152
synchronize do |client|
151-
client.call([:auth, password])
153+
client.call([:auth, *args])
152154
end
153155
end
154156

lib/redis/client.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Client
1717
write_timeout: nil,
1818
connect_timeout: nil,
1919
timeout: 5.0,
20+
username: nil,
2021
password: nil,
2122
db: 0,
2223
driver: nil,
@@ -61,6 +62,10 @@ def timeout
6162
@options[:read_timeout]
6263
end
6364

65+
def username
66+
@options[:username]
67+
end
68+
6469
def password
6570
@options[:password]
6671
end
@@ -110,7 +115,7 @@ def connect
110115
# Don't try to reconnect when the connection is fresh
111116
with_reconnect(false) do
112117
establish_connection
113-
call [:auth, password] if password
118+
call [:auth, username, password].compact if username || password
114119
call [:select, db] if db != 0
115120
call [:client, :setname, @options[:id]] if @options[:id]
116121
@connector.check(self)
@@ -434,7 +439,8 @@ def _parse_options(options)
434439
defaults[:scheme] = uri.scheme
435440
defaults[:host] = uri.host if uri.host
436441
defaults[:port] = uri.port if uri.port
437-
defaults[:password] = CGI.unescape(uri.password) if uri.password
442+
defaults[:username] = CGI.unescape(uri.user) if uri.user && !uri.user.empty?
443+
defaults[:password] = CGI.unescape(uri.password) if uri.password && !uri.password.empty?
438444
defaults[:db] = uri.path[1..-1].to_i if uri.path
439445
defaults[:role] = :master
440446
else
@@ -579,6 +585,7 @@ def sentinel_detect
579585
client = Client.new(@options.merge({
580586
host: sentinel[:host] || sentinel["host"],
581587
port: sentinel[:port] || sentinel["port"],
588+
username: sentinel[:username] || sentinel["username"],
582589
password: sentinel[:password] || sentinel["password"],
583590
reconnect_attempts: 0
584591
}))

lib/redis/cluster.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def fetch_command_details(nodes)
128128
def send_command(command, &block)
129129
cmd = command.first.to_s.downcase
130130
case cmd
131-
when 'auth', 'bgrewriteaof', 'bgsave', 'quit', 'save'
131+
when 'acl', 'auth', 'bgrewriteaof', 'bgsave', 'quit', 'save'
132132
@node.call_all(command, &block).first
133133
when 'flushall', 'flushdb'
134134
@node.call_master(command, &block).first

lib/redis/cluster/option.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def initialize(options)
1818
@node_opts = build_node_options(node_addrs)
1919
@replica = options.delete(:replica) == true
2020
add_common_node_option_if_needed(options, @node_opts, :scheme)
21+
add_common_node_option_if_needed(options, @node_opts, :username)
2122
add_common_node_option_if_needed(options, @node_opts, :password)
2223
@options = options
2324
end
@@ -63,7 +64,9 @@ def parse_node_url(addr)
6364
raise InvalidClientOptionError, "Invalid uri scheme #{addr}" unless VALID_SCHEMES.include?(uri.scheme)
6465

6566
db = uri.path.split('/')[1]&.to_i
66-
{ scheme: uri.scheme, password: uri.password, host: uri.host, port: uri.port, db: db }.reject { |_, v| v.nil? }
67+
68+
{ scheme: uri.scheme, username: uri.user, password: uri.password, host: uri.host, port: uri.port, db: db }
69+
.reject { |_, v| v.nil? || v == '' }
6770
rescue URI::InvalidURIError => err
6871
raise InvalidClientOptionError, err.message
6972
end
@@ -79,7 +82,7 @@ def parse_node_option(addr)
7982

8083
# Redis cluster node returns only host and port information.
8184
# So we should complement additional information such as:
82-
# scheme, password and so on.
85+
# scheme, username, password and so on.
8386
def add_common_node_option_if_needed(options, node_opts, key)
8487
return options if options[key].nil? && node_opts.first[key].nil?
8588

test/cluster_client_internals_test.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,23 @@ def test_connection
7474

7575
assert_equal expected, redis.connection
7676
end
77+
78+
def test_acl_auth_success
79+
target_version "6.0.0" do
80+
with_acl do |username, password|
81+
r = _new_client(cluster: DEFAULT_PORTS.map { |port| "redis://#{username}:#{password}@#{DEFAULT_HOST}:#{port}" })
82+
assert_equal('PONG', r.ping)
83+
end
84+
end
85+
end
86+
87+
def test_acl_auth_failure
88+
target_version "6.0.0" do
89+
with_acl do |username, _|
90+
assert_raises(Redis::CannotConnectError) do
91+
_new_client(cluster: DEFAULT_PORTS.map { |port| "redis://#{username}:wrongpassword@#{DEFAULT_HOST}:#{port}" })
92+
end
93+
end
94+
end
95+
end
7796
end

test/cluster_client_options_test.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ def test_option_class
2020
assert_equal false, option.use_replica?
2121

2222
option = Redis::Cluster::Option.new(cluster: %w[rediss://johndoe:[email protected]:7000/1/namespace])
23-
assert_equal({ '127.0.0.1:7000' => { scheme: 'rediss', password: 'foobar', host: '127.0.0.1', port: 7000, db: 1 } }, option.per_node_key)
23+
assert_equal({ '127.0.0.1:7000' => { scheme: 'rediss', username: 'johndoe', password: 'foobar', host: '127.0.0.1', port: 7000, db: 1 } }, option.per_node_key)
2424

2525
option = Redis::Cluster::Option.new(cluster: %w[rediss://127.0.0.1:7000], scheme: 'redis')
2626
assert_equal({ '127.0.0.1:7000' => { scheme: 'rediss', host: '127.0.0.1', port: 7000 } }, option.per_node_key)
2727

28+
option = Redis::Cluster::Option.new(cluster: %w[redis://bazzap:@127.0.0.1:7000], username: 'foobar')
29+
assert_equal({ '127.0.0.1:7000' => { scheme: 'redis', username: 'bazzap', host: '127.0.0.1', port: 7000 } }, option.per_node_key)
30+
2831
option = Redis::Cluster::Option.new(cluster: %w[redis://:[email protected]:7000], password: 'foobar')
2932
assert_equal({ '127.0.0.1:7000' => { scheme: 'redis', password: 'bazzap', host: '127.0.0.1', port: 7000 } }, option.per_node_key)
3033

test/cluster_commands_on_connection_test.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
# frozen_string_literal: true
22

33
require_relative 'helper'
4+
require 'lint/authentication'
45

56
# ruby -w -Itest test/cluster_commands_on_connection_test.rb
67
# @see https://redis.io/commands#connection
78
class TestClusterCommandsOnConnection < Minitest::Test
89
include Helper::Cluster
9-
10-
def test_auth
11-
redis_cluster_mock(auth: ->(*_) { '+OK' }) do |redis|
12-
assert_equal 'OK', redis.auth('my-password-123')
13-
end
14-
end
10+
include Lint::Authentication
1511

1612
def test_echo
1713
assert_equal 'hogehoge', redis.echo('hogehoge')
@@ -37,4 +33,8 @@ def test_swapdb
3733
redis.swapdb(1, 2)
3834
end
3935
end
36+
37+
def mock(*args, &block)
38+
redis_cluster_mock(*args, &block)
39+
end
4040
end

test/connection_handling_test.rb

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
# frozen_string_literal: true
22

33
require_relative "helper"
4+
require 'lint/authentication'
45

56
class TestConnectionHandling < Minitest::Test
67
include Helper::Client
7-
8-
def test_auth
9-
commands = {
10-
auth: ->(password) { @auth = password; "+OK" },
11-
get: ->(_key) { @auth == "secret" ? "$3\r\nbar" : "$-1" }
12-
}
13-
14-
redis_mock(commands, password: "secret") do |redis|
15-
assert_equal "bar", redis.get("foo")
16-
end
17-
end
8+
include Lint::Authentication
189

1910
def test_id
2011
commands = {

test/helper.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,16 @@ def omit_version(min_ver)
165165
def version
166166
Version.new(redis.info['redis_version'])
167167
end
168+
169+
def with_acl
170+
admin = _new_client
171+
admin.acl('SETUSER', 'johndoe', 'on',
172+
'+ping', '+select', '+command', '+cluster|slots', '+cluster|nodes',
173+
'>mysecret')
174+
yield('johndoe', 'mysecret')
175+
admin.acl('DELUSER', 'johndoe')
176+
admin.close
177+
end
168178
end
169179

170180
module Client

0 commit comments

Comments
 (0)