Skip to content

Commit 367d0b6

Browse files
Merge pull request #14414 from lukebakken/lukebakken/ldap-validation-api
Implement LDAP credentials validation via HTTP API
2 parents a09383d + 773e754 commit 367d0b6

File tree

6 files changed

+747
-14
lines changed

6 files changed

+747
-14
lines changed

deps/rabbitmq_auth_backend_ldap/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ define PROJECT_APP_EXTRA_KEYS
3535
endef
3636

3737
LOCAL_DEPS = eldap public_key
38-
DEPS = rabbit_common rabbit
38+
DEPS = rabbit_common rabbit rabbitmq_management
3939
TEST_DEPS = ct_helper rabbitmq_ct_helpers rabbitmq_ct_client_helpers amqp_client
4040
dep_ct_helper = git https://github.com/extend/ct_helper.git master
4141

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
%% This Source Code Form is subject to the terms of the Mozilla Public
2+
%% License, v. 2.0. If a copy of the MPL was not distributed with this
3+
%% file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
%%
5+
%% Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved.
6+
%%
7+
8+
-module(rabbit_auth_backend_ldap_mgmt).
9+
10+
-behaviour(rabbit_mgmt_extension).
11+
12+
-export([dispatcher/0, web_ui/0]).
13+
14+
-export([init/2,
15+
content_types_accepted/2,
16+
allowed_methods/2,
17+
resource_exists/2,
18+
is_authorized/2,
19+
accept_content/2]).
20+
21+
22+
-include_lib("kernel/include/logger.hrl").
23+
-include_lib("rabbitmq_web_dispatch/include/rabbitmq_web_dispatch_records.hrl").
24+
25+
dispatcher() -> [{"/ldap/validate/simple-bind", ?MODULE, []}].
26+
27+
web_ui() -> [].
28+
29+
%%--------------------------------------------------------------------
30+
31+
init(Req, _Opts) ->
32+
{cowboy_rest, rabbit_mgmt_cors:set_headers(Req, ?MODULE), #context{}}.
33+
34+
content_types_accepted(ReqData, Context) ->
35+
{[{'*', accept_content}], ReqData, Context}.
36+
37+
allowed_methods(ReqData, Context) ->
38+
{[<<"PUT">>, <<"OPTIONS">>], ReqData, Context}.
39+
40+
resource_exists(ReqData, Context) ->
41+
{true, ReqData, Context}.
42+
43+
is_authorized(ReqData, Context) ->
44+
rabbit_mgmt_util:is_authorized(ReqData, Context).
45+
46+
accept_content(ReqData0, Context) ->
47+
F = fun (_Values, BodyMap, ReqData1) ->
48+
try
49+
Port = safe_parse_int(maps:get(port, BodyMap, 389), "port"),
50+
UseSsl = safe_parse_bool(maps:get(use_ssl, BodyMap, false), "use_ssl"),
51+
UseStartTls = safe_parse_bool(maps:get(use_starttls, BodyMap, false), "use_starttls"),
52+
Servers = maps:get(servers, BodyMap, []),
53+
UserDN = maps:get(user_dn, BodyMap, <<"">>),
54+
Password = maps:get(password, BodyMap, <<"">>),
55+
Options0 = [
56+
{port, Port},
57+
{timeout, 5000}
58+
],
59+
{ok, Options1} = maybe_add_ssl_options(Options0, UseSsl, BodyMap),
60+
case eldap:open(Servers, Options1) of
61+
{ok, LDAP} ->
62+
Result = case maybe_starttls(LDAP, UseStartTls, BodyMap) of
63+
ok ->
64+
case eldap:simple_bind(LDAP, UserDN, Password) of
65+
ok ->
66+
{true, ReqData1, Context};
67+
{error, invalidCredentials} ->
68+
rabbit_mgmt_util:unprocessable_entity("invalid LDAP credentials: "
69+
"authentication failure",
70+
ReqData1, Context);
71+
{error, unwillingToPerform} ->
72+
rabbit_mgmt_util:unprocessable_entity("invalid LDAP credentials: "
73+
"authentication failure",
74+
ReqData1, Context);
75+
{error, invalidDNSyntax} ->
76+
rabbit_mgmt_util:unprocessable_entity("invalid LDAP credentials: "
77+
"DN syntax invalid / too long",
78+
ReqData1, Context);
79+
{error, E} ->
80+
Reason = unicode_format(E),
81+
rabbit_mgmt_util:unprocessable_entity(Reason, ReqData1, Context)
82+
end;
83+
{error, tls_already_started} ->
84+
rabbit_mgmt_util:unprocessable_entity("TLS configuration error: "
85+
"cannot use StartTLS on an SSL connection "
86+
"(use_ssl and use_starttls cannot both be true)",
87+
ReqData1, Context);
88+
Error ->
89+
Reason = unicode_format(Error),
90+
rabbit_mgmt_util:unprocessable_entity(Reason, ReqData1, Context)
91+
end,
92+
eldap:close(LDAP),
93+
Result;
94+
{error, E} ->
95+
Reason = unicode_format("LDAP connection failed: ~tp "
96+
"(servers: ~tp, user_dn: ~ts, password: ~s)",
97+
[E, Servers, UserDN, format_password_for_logging(Password)]),
98+
rabbit_mgmt_util:bad_request(Reason, ReqData1, Context)
99+
end
100+
catch throw:{bad_request, ErrMsg} ->
101+
rabbit_mgmt_util:bad_request(ErrMsg, ReqData1, Context)
102+
end
103+
end,
104+
rabbit_mgmt_util:with_decode([], ReqData0, Context, F).
105+
106+
%%--------------------------------------------------------------------
107+
108+
maybe_starttls(_LDAP, false, _BodyMap) ->
109+
ok;
110+
maybe_starttls(LDAP, true, BodyMap) ->
111+
{ok, TlsOptions} = tls_options(BodyMap),
112+
eldap:start_tls(LDAP, TlsOptions, 5000).
113+
114+
maybe_add_ssl_options(Options0, false, _BodyMap) ->
115+
{ok, Options0};
116+
maybe_add_ssl_options(Options0, true, BodyMap) ->
117+
case maps:is_key(ssl_options, BodyMap) of
118+
false ->
119+
{ok, Options0};
120+
true ->
121+
Options1 = [{ssl, true} | Options0],
122+
{ok, TlsOptions} = tls_options(BodyMap),
123+
Options2 = [{sslopts, TlsOptions} | Options1],
124+
{ok, Options2}
125+
end.
126+
127+
tls_options(BodyMap) when is_map_key(ssl_options, BodyMap) ->
128+
SslOptionsMap = maps:get(ssl_options, BodyMap),
129+
case is_map(SslOptionsMap) of
130+
false ->
131+
throw({bad_request, "ssl_options must be a map/object"});
132+
true ->
133+
ok
134+
end,
135+
CaCertfile = maps:get(<<"cacertfile">>, SslOptionsMap, undefined),
136+
CaCertPemData = maps:get(<<"cacert_pem_data">>, SslOptionsMap, undefined),
137+
TlsOpts0 = case {CaCertfile, CaCertPemData} of
138+
{undefined, undefined} ->
139+
[{cacerts, public_key:cacerts_get()}];
140+
_ ->
141+
[]
142+
end,
143+
%% NB: for some reason the "cacertfile" key isn't turned into an atom
144+
TlsOpts1 = case CaCertfile of
145+
undefined ->
146+
TlsOpts0;
147+
CaCertfile ->
148+
[{cacertfile, CaCertfile} | TlsOpts0]
149+
end,
150+
TlsOpts2 = case CaCertPemData of
151+
undefined ->
152+
TlsOpts1;
153+
CaCertPems when is_list(CaCertPems) ->
154+
F0 = fun (P) ->
155+
try
156+
case public_key:pem_decode(P) of
157+
[{'Certificate', CaCertDerEncoded, not_encrypted}] ->
158+
{true, CaCertDerEncoded};
159+
[] ->
160+
throw({bad_request, "invalid PEM data in cacert_pem_data: "
161+
"no valid certificates found"});
162+
_Unexpected ->
163+
throw({bad_request, "unexpected cacert_pem_data passed to "
164+
"/ldap/validate/simple-bind ssl_options.cacerts"})
165+
end
166+
catch
167+
error:Reason ->
168+
throw({bad_request, unicode_format("invalid PEM data in cacert_pem_data: ~tp", [Reason])})
169+
end
170+
end,
171+
CaCertsDerEncoded = lists:filtermap(F0, CaCertPems),
172+
[{cacerts, CaCertsDerEncoded} | TlsOpts1];
173+
_ ->
174+
TlsOpts1
175+
end,
176+
TlsOpts3 = case maps:get(<<"verify">>, SslOptionsMap, undefined) of
177+
undefined ->
178+
TlsOpts2;
179+
Verify ->
180+
try
181+
VerifyStr = unicode:characters_to_list(Verify),
182+
[{verify, list_to_existing_atom(VerifyStr)} | TlsOpts2]
183+
catch
184+
error:badarg ->
185+
throw({bad_request, "invalid verify option passed to "
186+
"/ldap/validate/simple-bind ssl_options.verify"})
187+
end
188+
end,
189+
TlsOpts4 = case maps:get(<<"server_name_indication">>, SslOptionsMap, disable) of
190+
disable ->
191+
TlsOpts3;
192+
SniValue ->
193+
try
194+
SniStr = unicode:characters_to_list(SniValue),
195+
[{server_name_indication, SniStr} | TlsOpts3]
196+
catch
197+
error:badarg ->
198+
throw({bad_request, "invalid server_name_indication: expected string"});
199+
error:_ ->
200+
throw({bad_request, "invalid server_name_indication: expected string"})
201+
end
202+
end,
203+
TlsOpts5 = case maps:get(<<"depth">>, SslOptionsMap, undefined) of
204+
undefined ->
205+
TlsOpts4;
206+
DepthValue ->
207+
Depth = safe_parse_int(DepthValue, "ssl_options.depth"),
208+
[{depth, Depth} | TlsOpts4]
209+
end,
210+
TlsOpts6 = case maps:get(<<"versions">>, SslOptionsMap, undefined) of
211+
undefined ->
212+
TlsOpts5;
213+
VersionStrs when is_list(VersionStrs) ->
214+
F1 = fun (VStr) ->
215+
try
216+
{true, list_to_existing_atom(VStr)}
217+
catch error:badarg ->
218+
throw({bad_request, "invalid TLS version passed to "
219+
"/ldap/validate/simple-bind ssl_options.versions"})
220+
end
221+
end,
222+
Versions = lists:filtermap(F1, VersionStrs),
223+
[{versions, Versions} | TlsOpts5]
224+
end,
225+
TlsOpts7 = case maps:get(<<"ssl_hostname_verification">>, SslOptionsMap, undefined) of
226+
undefined ->
227+
TlsOpts6;
228+
"wildcard" ->
229+
[{customize_hostname_check, [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]} | TlsOpts6];
230+
_ ->
231+
throw({bad_request, "invalid value passed to "
232+
"/ldap/validate/simple-bind ssl_options.ssl_hostname_verification"})
233+
end,
234+
{ok, TlsOpts7};
235+
tls_options(_BodyMap) ->
236+
{ok, []}.
237+
238+
unicode_format(Arg) ->
239+
rabbit_data_coercion:to_utf8_binary(io_lib:format("~tp", [Arg])).
240+
241+
unicode_format(Format, Args) ->
242+
rabbit_data_coercion:to_utf8_binary(io_lib:format(Format, Args)).
243+
244+
format_password_for_logging(<<>>) ->
245+
"[empty]";
246+
format_password_for_logging(Password) ->
247+
io_lib:format("[~p characters]", [string:length(Password)]).
248+
249+
safe_parse_int(Value, FieldName) ->
250+
try
251+
rabbit_mgmt_util:parse_int(Value)
252+
catch
253+
throw:{error, {not_integer, BadValue}} ->
254+
Msg = unicode_format("invalid value for ~s: expected integer, got ~tp",
255+
[FieldName, BadValue]),
256+
throw({bad_request, Msg})
257+
end.
258+
259+
safe_parse_bool(Value, FieldName) ->
260+
try
261+
rabbit_mgmt_util:parse_bool(Value)
262+
catch
263+
throw:{error, {not_boolean, BadValue}} ->
264+
Msg = unicode_format("invalid value for ~s: expected boolean, got ~tp",
265+
[FieldName, BadValue]),
266+
throw({bad_request, Msg})
267+
end.

0 commit comments

Comments
 (0)