|
| 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