├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── include └── couch_db.hrl ├── priv ├── default.d │ └── ldap_auth.ini └── local.d │ └── ldap_auth.ini ├── rebar.config ├── src ├── ldap_auth.app.src ├── ldap_auth.erl ├── ldap_auth_config.erl └── ldap_auth_gateway.erl └── test ├── config_tests.erl ├── ldap_integration_tests.erl └── test_config.erl /.gitignore: -------------------------------------------------------------------------------- 1 | .eunit 2 | /deps/* 3 | *.o 4 | *.beam 5 | *.plt 6 | erl_crash.dump 7 | /out 8 | /ebin 9 | .idea 10 | *.iml 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.0.0 4 | 5 | ### Fixes 6 | 7 | - Issue #2: handle_admin_role is now functional. 8 | 9 | ### Enhancements 10 | 11 | - Issue #7: All groups are now lower-cased. **Potentially backwards-incompatible** 12 | - Issue #3: The `LdapServer` config has been replaced with `LdapServers`, which accepts a comma-separated list of servers to try. **Backwards-incompatible** 13 | 14 | ## v1.0.0 15 | 16 | Initial version. Supports authenticating users and groups against a specified LDAP server. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Daniel Moore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LDAP Authentication Handler for CouchDB 2 | 3 | ## Features 4 | - Basic authentication handler 5 | - Session handler 6 | - works with CouchDB built-in `cookie_authentication_handler` 7 | - Supports users and recursive-group association 8 | 9 | ## Installation 10 | 11 | First, check out the source and `cd` into it. 12 | 13 | ### Prerequisites 14 | - Erlang 15 | - Rebar 16 | - eldap (bundled with Erlang >= R15B. See their [github repo][eldap].) 17 | 18 | [eldap]: https://github.com/etnt/eldap 19 | 20 | ### Build from Source 21 | 22 | ``` 23 | rebar get-deps clean compile 24 | ``` 25 | 26 | ### Run Tests 27 | 28 | ``` 29 | rebar test 30 | ``` 31 | 32 | ### Install binaries and config 33 | 34 | ``` 35 | # Create module folder 36 | mkdir /usr/local/lib/couchdb/erlang/lib/ldap-auth 37 | # Copy binaries 38 | cp -R ebin /usr/local/lib/couchdb/erlang/lib/ldap-auth/ 39 | 40 | # Copy/overwrite the default config 41 | cp -f priv/default.d/* /usr/local/etc/couchdb/default.d/ 42 | 43 | # Copy (but don't overwrite!) the custom config 44 | cp -n priv/local.d/* /usr/local/etc/couchdb/local.d/ 45 | ``` 46 | 47 | ## Configuration 48 | 49 | ### Authentication Handlers 50 | 51 | The defaults included in `ldap_auth.ini` provide a basic, out-of-the-box 52 | configuration for sessions, cookies, basic authentication, and system admin 53 | role assignment based on LDAP. 54 | 55 | If you have a custom configuration of CouchDB, you may need to edit it. 56 | 57 | Keep in mind that the first handler to authenticate a credential "wins." 58 | Specifically, this means that if you keep the built-in 59 | `{couch_httpd_auth, default_authentication_handler}`, CouchDB will continue 60 | to inspect the `_users` database for credentials and use the ini files 61 | for system admins. 62 | 63 | #### Basic Auth 64 | 65 | To allow requests with the users' names and passwords encoded in the URL, 66 | simply include ` {ldap_auth, handle_basic_auth_req}` in the 67 | `authentication_handlers`: 68 | 69 | [httpd] 70 | authentication_handlers = {ldap_auth, handle_basic_auth_req} 71 | 72 | #### Sessions and Cookies 73 | 74 | In order for session management and cookies to work, you need a few options set. 75 | 76 | The first binds the `/_session` REST endpoint to the LDAP session manager. 77 | 78 | ```ini 79 | [httpd_global_handlers] 80 | _session = {ldap_auth, handle_session_req} 81 | ``` 82 | 83 | The session manager will authenticate the POST payload credentials and provide 84 | a cookie token. 85 | 86 | In order to use the cookie token, the CouchDB built-in cookie handler must be 87 | included in the list of authentication handlers: 88 | 89 | [httpd] 90 | authentication_handlers = {couch_httpd_auth, cookie_authentication_handler} 91 | 92 | Each time `handle_session_req` is called, the `_users` database is updated 93 | with the user's roles. If the user document does not exist beforehand, a new one 94 | is created; the user's password is not stored. 95 | 96 | #### System Admin Delegation 97 | 98 | If you'd like to use LDAP to also control the list of system administrators, 99 | rather than the CouchDB built-in list in .ini files, you can add 100 | `{ldap_auth, handle_admin_role}` to the end of the `authentication_handlers` 101 | list. 102 | 103 | ### Options 104 | 105 | #### UseSSL 106 | 107 | Set to `true` to use SSL to bind to the LDAP server. Default: `false` 108 | 109 | #### LdapServers 110 | 111 | The LDAP servers to use for searches and authentication, separated by commas. These will be tried in-order. 112 | 113 | #### BaseDN 114 | 115 | The distinguished name to constrain the scope of which users may authenticate. 116 | This may be as broad (the entire domain) or narrow (an OU or even a group) as 117 | needed. 118 | 119 | #### SearchUserDN and SearchUserPassword 120 | 121 | In order to authenticate users by an arbitrary attribute (like username) instead 122 | of a distinguished name, a service user must be available with permission to 123 | query LDAP (no other permissions are needed). Some LDAP servers provide anonymous 124 | querying, but this is not recommended by LDAP vendors. 125 | 126 | The SearchUserDN and SearchUserPassword should be set to the credentials of the 127 | desired service user. If anonymous queries are allowed and preferred, the DN 128 | must be set to the anon DN, but the password may remain blank. 129 | 130 | #### UserDNMapAttr 131 | 132 | The attribute to use as the login name for CouchDB. On Active Directory, you 133 | might use: 134 | 135 | - `sAMAccountName`, e.g. jsmith 136 | - `userPrincipalName`, e.g. jsmith@example.com
137 | NOTE: if you use userPrincipalName, be sure to URL-encode the username when using basic auth.
138 | e.g. `http://jsmith%40example.com:password@example.com:5984` 139 | 140 | Any attribute could be used, though. 141 | 142 | #### GroupDNMapAttr 143 | 144 | The same as UserDNMapAttr, but for groups. Most LDAP software has a `name` 145 | attribute on group objects. 146 | 147 | #### SystemAdminRoleName 148 | 149 | If you're using system admin delegation, this is the name of the role that will 150 | be promoted to `_admin`, aka the system admin. 151 | -------------------------------------------------------------------------------- /include/couch_db.hrl: -------------------------------------------------------------------------------- 1 | % Licensed under the Apache License, Version 2.0 (the "License"); you may not 2 | % use this file except in compliance with the License. You may obtain a copy of 3 | % the License at 4 | % 5 | % http://www.apache.org/licenses/LICENSE-2.0 6 | % 7 | % Unless required by applicable law or agreed to in writing, software 8 | % distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | % WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | % License for the specific language governing permissions and limitations under 11 | % the License. 12 | 13 | -define(LOCAL_DOC_PREFIX, "_local/"). 14 | -define(DESIGN_DOC_PREFIX0, "_design"). 15 | -define(DESIGN_DOC_PREFIX, "_design/"). 16 | -define(DEFAULT_COMPRESSION, snappy). 17 | 18 | -define(MIN_STR, <<"">>). 19 | -define(MAX_STR, <<255>>). % illegal utf string 20 | 21 | % the lowest possible database sequence number 22 | -define(LOWEST_SEQ, 0). 23 | 24 | -define(REWRITE_COUNT, couch_rewrite_count). 25 | 26 | -define(JSON_ENCODE(V), ejson:encode(V)). 27 | -define(JSON_DECODE(V), ejson:decode(V)). 28 | 29 | -define(b2l(V), binary_to_list(V)). 30 | -define(l2b(V), list_to_binary(V)). 31 | -define(term_to_bin(T), term_to_binary(T, [{minor_version, 1}])). 32 | -define(term_size(T), 33 | try 34 | erlang:external_size(T) 35 | catch _:_ -> 36 | byte_size(?term_to_bin(T)) 37 | end). 38 | 39 | -define(DEFAULT_ATTACHMENT_CONTENT_TYPE, <<"application/octet-stream">>). 40 | 41 | -define(LOG_DEBUG(Format, Args), 42 | case couch_log:debug_on(?MODULE) of 43 | true -> 44 | couch_log:debug(Format, Args); 45 | false -> ok 46 | end). 47 | 48 | -define(LOG_INFO(Format, Args), 49 | case couch_log:info_on(?MODULE) of 50 | true -> 51 | couch_log:info(Format, Args); 52 | false -> ok 53 | end). 54 | 55 | -define(LOG_WARN(Format, Args), 56 | case couch_log:warn_on(?MODULE) of 57 | true -> 58 | couch_log:warn(Format, Args); 59 | false -> ok 60 | end). 61 | 62 | -define(LOG_ERROR(Format, Args), couch_log:error(Format, Args)). 63 | 64 | % Tree::term() is really a tree(), but we don't want to require R13B04 yet 65 | -type branch() :: {Key::term(), Value::term(), Tree::term()}. 66 | -type path() :: {Start::pos_integer(), branch()}. 67 | -type tree() :: [branch()]. % sorted by key 68 | 69 | -record(rev_info, 70 | { 71 | rev, 72 | seq = 0, 73 | deleted = false, 74 | body_sp = nil % stream pointer 75 | }). 76 | 77 | -record(doc_info, 78 | { 79 | id = <<"">>, 80 | high_seq = 0, 81 | revs = [] % rev_info 82 | }). 83 | 84 | -record(full_doc_info, 85 | {id = <<"">>, 86 | update_seq = 0, 87 | deleted = false, 88 | rev_tree = [], 89 | leafs_size = 0 90 | }). 91 | 92 | -record(httpd, 93 | {mochi_req, 94 | peer, 95 | method, 96 | requested_path_parts, 97 | path_parts, 98 | db_url_handlers, 99 | user_ctx, 100 | req_body = undefined, 101 | design_url_handlers, 102 | auth, 103 | default_fun, 104 | url_handlers 105 | }). 106 | 107 | 108 | -record(doc, 109 | { 110 | id = <<"">>, 111 | revs = {0, []}, 112 | 113 | % the json body object. 114 | body = {[]}, 115 | 116 | atts = [], % attachments 117 | 118 | deleted = false, 119 | 120 | % key/value tuple of meta information, provided when using special options: 121 | % couch_db:open_doc(Db, Id, Options). 122 | meta = [] 123 | }). 124 | 125 | 126 | -record(att, 127 | { 128 | name, 129 | type, 130 | att_len, 131 | disk_len, % length of the attachment in its identity form 132 | % (that is, without a content encoding applied to it) 133 | % differs from att_len when encoding /= identity 134 | md5= <<>>, 135 | revpos=0, 136 | data, 137 | encoding=identity % currently supported values are: 138 | % identity, gzip 139 | % additional values to support in the future: 140 | % deflate, compress 141 | }). 142 | 143 | 144 | -record(user_ctx, 145 | { 146 | name=null, 147 | roles=[], 148 | handler 149 | }). 150 | 151 | % This should be updated anytime a header change happens that requires more 152 | % than filling in new defaults. 153 | % 154 | % As long the changes are limited to new header fields (with inline 155 | % defaults) added to the end of the record, then there is no need to increment 156 | % the disk revision number. 157 | % 158 | % if the disk revision is incremented, then new upgrade logic will need to be 159 | % added to couch_db_updater:init_db. 160 | 161 | -define(LATEST_DISK_VERSION, 6). 162 | 163 | -record(db_header, 164 | {disk_version = ?LATEST_DISK_VERSION, 165 | update_seq = 0, 166 | unused = 0, 167 | fulldocinfo_by_id_btree_state = nil, 168 | docinfo_by_seq_btree_state = nil, 169 | local_docs_btree_state = nil, 170 | purge_seq = 0, 171 | purged_docs = nil, 172 | security_ptr = nil, 173 | revs_limit = 1000 174 | }). 175 | 176 | -record(db, 177 | {main_pid = nil, 178 | update_pid = nil, 179 | compactor_pid = nil, 180 | instance_start_time, % number of microsecs since jan 1 1970 as a binary string 181 | fd, 182 | updater_fd, 183 | fd_ref_counter, 184 | header = #db_header{}, 185 | committed_update_seq, 186 | fulldocinfo_by_id_btree, 187 | docinfo_by_seq_btree, 188 | local_docs_btree, 189 | update_seq, 190 | name, 191 | filepath, 192 | validate_doc_funs = [], 193 | security = [], 194 | security_ptr = nil, 195 | user_ctx = #user_ctx{}, 196 | waiting_delayed_commit = nil, 197 | revs_limit = 1000, 198 | fsync_options = [], 199 | options = [], 200 | compression, 201 | before_doc_update = nil, % nil | fun(Doc, Db) -> NewDoc 202 | after_doc_read = nil % nil | fun(Doc, Db) -> NewDoc 203 | }). 204 | 205 | 206 | -record(view_query_args, { 207 | start_key, 208 | end_key, 209 | start_docid = ?MIN_STR, 210 | end_docid = ?MAX_STR, 211 | 212 | direction = fwd, 213 | inclusive_end=true, % aka a closed-interval 214 | 215 | limit = 10000000000, % Huge number to simplify logic 216 | skip = 0, 217 | 218 | group_level = 0, 219 | 220 | view_type = nil, 221 | include_docs = false, 222 | conflicts = false, 223 | stale = false, 224 | multi_get = false, 225 | callback = nil, 226 | list = nil 227 | }). 228 | 229 | -record(view_fold_helper_funs, { 230 | reduce_count, 231 | passed_end, 232 | start_response, 233 | send_row 234 | }). 235 | 236 | -record(reduce_fold_helper_funs, { 237 | start_response, 238 | send_row 239 | }). 240 | 241 | -record(extern_resp_args, { 242 | code = 200, 243 | stop = false, 244 | data = <<>>, 245 | ctype = "application/json", 246 | headers = [], 247 | json = nil 248 | }). 249 | 250 | -record(index_header, 251 | {seq=0, 252 | purge_seq=0, 253 | id_btree_state=nil, 254 | view_states=nil 255 | }). 256 | 257 | % small value used in revision trees to indicate the revision isn't stored 258 | -define(REV_MISSING, []). 259 | 260 | -record(changes_args, { 261 | feed = "normal", 262 | dir = fwd, 263 | since = 0, 264 | limit = 1000000000000000, 265 | style = main_only, 266 | heartbeat, 267 | timeout, 268 | filter = "", 269 | filter_fun, 270 | filter_args = [], 271 | include_docs = false, 272 | conflicts = false, 273 | db_open_options = [] 274 | }). 275 | 276 | -record(btree, { 277 | fd, 278 | root, 279 | extract_kv = fun({_Key, _Value} = KV) -> KV end, 280 | assemble_kv = fun(Key, Value) -> {Key, Value} end, 281 | less = fun(A, B) -> A < B end, 282 | reduce = nil, 283 | compression = ?DEFAULT_COMPRESSION 284 | }). 285 | -------------------------------------------------------------------------------- /priv/default.d/ldap_auth.ini: -------------------------------------------------------------------------------- 1 | [ldap_auth] 2 | UseSsl = false 3 | LdapServer = 4 | BaseDN = 5 | SearchUserDN = 6 | SearchUserPassword = 7 | UserDNMapAttr = sAMAccountName 8 | GroupDNMapAttr = name 9 | SystemAdminRoleName = admin 10 | -------------------------------------------------------------------------------- /priv/local.d/ldap_auth.ini: -------------------------------------------------------------------------------- 1 | [httpd_global_handlers] 2 | _session = {ldap_auth, handle_session_req} 3 | 4 | [httpd] 5 | authentication_handlers = {ldap_auth, handle_admin_role} 6 | 7 | [ldap_auth] 8 | ; NOTE: for all of the following configurations, if the key is suffixed in "DN", ldap_auth 9 | ; will expect you to provide a real LDAP Distinguished Name. 10 | 11 | ; If you use handle_admin_role to assign your system admins, specify the authentication handlers it should 12 | ; query here. See SystemAdminRoleName for more details. 13 | AuthenticationHandlers = {couch_httpd_auth, cookie_authentication_handler}, {ldap_auth, handle_basic_auth_req} 14 | 15 | ; Enable SSL to the LDAP server. 16 | UseSsl = false 17 | 18 | ; The LDAP servers to use for searches and authentication, separated by commas. These will be tried in-order. 19 | LdapServers = first.ldap.example.com, second.ldap.example.com, third.ldap.example.com 20 | 21 | ; The DN to narrow the scope of searches for users and groups. 22 | BaseDN = DC=example,DC=com 23 | 24 | ; ldap_auth will use this user DN and password to search for users trying to authenticate. 25 | ; if you have anonymous LDAP queries enabled (not recommended) you may simply provide the 26 | ; `anon` CN and a blank password. 27 | SearchUserDN = CN=ldapsearch,CN=Users,DC=example,DC=com 28 | SearchUserPassword = ldapsearch_password_here 29 | 30 | ; On ActiveDirectory, you might choose from: 31 | ; - sAMAccountName, e.g. jsmith 32 | ; - userPrincipalName, e.g. jsmith@example.com 33 | ; NOTE: if you use userPrincipalName, be sure to URL-encode the username when using basic auth. 34 | ; e.g. http://jsmith%40example.com:password@example.com:5984 35 | UserDNMapAttr = sAMAccountName 36 | 37 | ; The LDAP attribute of the group to use as the role name. 38 | GroupDNMapAttr = name 39 | 40 | ; The role to grant system administrative privileges to. 41 | ; If you include {ldap_auth, handle_admin_role} in your authentication_handlers, it will 42 | ; grant the system admin role to anyone who has this role assigned. BE CAREFUL. 43 | SystemAdminRoleName = admin 44 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | %%-*- mode: erlang -*- 2 | {deps, [ 3 | {meck, "0.8.2", {git, "https://github.com/eproxus/meck.git", {tag, "0.8.2"}}} 4 | ]}. 5 | -------------------------------------------------------------------------------- /src/ldap_auth.app.src: -------------------------------------------------------------------------------- 1 | {application, ldap_auth, 2 | [ 3 | {description, ""}, 4 | {vsn, "2.0.0"}, 5 | {registered, [ "src/ldap_auth" ]}, 6 | {applications, [ 7 | kernel, 8 | stdlib 9 | ]}, 10 | {mod, { ldap_auth_app, []}}, 11 | {env, []} 12 | ]}. 13 | -------------------------------------------------------------------------------- /src/ldap_auth.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author dmoore 3 | %%% @copyright (C) 2013, 4 | %%% @doc 5 | %%% 6 | %%% @end 7 | %%% Created : 13. Oct 2013 6:09 PM 8 | %%%------------------------------------------------------------------- 9 | -module(ldap_auth). 10 | -author("dmoore"). 11 | -include("couch_db.hrl"). 12 | 13 | -define(replace(L, K, V), lists:keystore(K, 1, L, {K, V})). 14 | 15 | %% API 16 | -export([handle_basic_auth_req/1, handle_admin_role/1]). 17 | -export([handle_session_req/1]). 18 | 19 | -import(couch_httpd, [header_value/2, send_json/2, send_json/4, send_method_not_allowed/2]). 20 | 21 | -import(ldap_auth_config, [get_config/1]). 22 | -import(ldap_auth_gateway, [connect/0, authenticate/3, get_user_dn/2, get_group_memberships/2]). 23 | 24 | % many functions in here are taken from or based on things here: 25 | % https://github.com/davisp/couchdb/blob/5d4ef93048f4aca24bef00fb5b2c13c54c2bbbb3/src/couchdb/couch_httpd_auth.erl 26 | 27 | handle_basic_auth_req(Req) -> 28 | case basic_name_pw(Req) of 29 | {UserName, Password} -> 30 | case authenticate_user(UserName, Password) of 31 | {ok, Roles} -> 32 | Req#httpd{ 33 | user_ctx = #user_ctx { 34 | name = ?l2b(UserName), 35 | roles = Roles 36 | } 37 | }; 38 | _ -> Req 39 | end; 40 | nil -> 41 | Req 42 | end. 43 | 44 | handle_admin_role(Req) -> 45 | % This is a workaround pending a resolution to https://issues.apache.org/jira/browse/COUCHDB-2034 46 | [AuthenticationHandlers] = get_config(["AuthenticationHandlers"]), 47 | {ok, Tokens, _} = erl_scan:string("[" ++ AuthenticationHandlers ++ "]."), 48 | {ok, Term} = erl_parse:parse_term(Tokens), 49 | AuthedReq = run_auth_handlers(Req, Term), 50 | prepend_admin_role(AuthedReq). 51 | 52 | prepend_admin_role(#httpd{ user_ctx = #user_ctx{ name = User, roles = Roles } = UserCtx } = Req) when length(Roles) > 0 -> 53 | [SystemAdminRoleName] = get_config(["SystemAdminRoleName"]), 54 | ?LOG_DEBUG("Checking for system admin role ~p for user ~p with roles: ~p", [ SystemAdminRoleName, User, Roles ]), 55 | case lists:member(?l2b(SystemAdminRoleName), Roles) of 56 | true -> Req#httpd{ user_ctx = UserCtx#user_ctx{ roles = [<<"_admin">>|Roles] } }; 57 | _ -> Req 58 | end; 59 | prepend_admin_role(#httpd{} = Req) -> Req. 60 | 61 | run_auth_handlers(Req, []) -> Req; 62 | run_auth_handlers(Req, [ {Mod, Fun} | Rem]) -> run_auth_handlers(Mod:Fun(Req), Rem); 63 | run_auth_handlers(Req, [ {Mod, Fun, SpecArg} | Rem]) -> run_auth_handlers(Mod:Fun(Req, SpecArg), Rem). 64 | 65 | % session handlers 66 | % Login handler with user db 67 | handle_session_req(#httpd{method='POST', mochi_req=MochiReq}=Req) -> 68 | {UserName, Password} = get_req_credentials(Req), 69 | ?LOG_DEBUG("Attempt Login: ~s",[UserName]), 70 | User = case couch_auth_cache:get_user_creds(UserName) of 71 | nil -> []; 72 | Result -> Result 73 | end, 74 | UserSalt = couch_util:get_value(<<"salt">>, User, <<>>), 75 | case authenticate_user(UserName, Password) of 76 | {ok, Roles} -> 77 | set_user_roles(UserName, Roles), 78 | 79 | % setup the session cookie 80 | Secret = ?l2b(ensure_cookie_auth_secret()), 81 | CurrentTime = make_cookie_time(), 82 | Cookie = cookie_auth_cookie(Req, ?b2l(UserName), <>, CurrentTime), 83 | % TODO document the "next" feature in Futon 84 | {Code, Headers} = redirect_or_default(Req, "next", {200, [Cookie]}), 85 | send_json(Req#httpd{req_body=MochiReq:recv_body()}, Code, Headers, 86 | {[ 87 | {ok, true}, 88 | {name, UserName}, 89 | {roles, Roles} 90 | ]}); 91 | _Else -> 92 | % clear the session 93 | Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}] ++ cookie_scheme(Req)), 94 | {Code, Headers} = redirect_or_default(Req, "fail", {401, [Cookie]}), 95 | send_json(Req, Code, Headers, {[{error, <<"unauthorized">>},{reason, <<"Name or password is incorrect.">>}]}) 96 | end; 97 | % get user info 98 | % GET /_session 99 | handle_session_req(#httpd{method='GET', user_ctx=UserCtx}=Req) -> 100 | Name = UserCtx#user_ctx.name, 101 | ForceLogin = couch_httpd:qs_value(Req, "basic", "false"), 102 | case {Name, ForceLogin} of 103 | {null, "true"} -> 104 | throw({unauthorized, <<"Please login.">>}); 105 | {Name, _} -> 106 | send_json(Req, {[ 107 | % remove this ok 108 | {ok, true}, 109 | {<<"userCtx">>, {[ 110 | {name, Name}, 111 | {roles, UserCtx#user_ctx.roles} 112 | ]}}, 113 | {info, {get_auth_info(Req)}} 114 | ]}) 115 | end; 116 | % logout by deleting the session 117 | handle_session_req(#httpd{method='DELETE'}=Req) -> 118 | Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}] ++ cookie_scheme(Req)), 119 | {Code, Headers} = redirect_or_default(Req, "next", {200, [Cookie]}), 120 | send_json(Req, Code, Headers, {[{ok, true}]}); 121 | handle_session_req(Req) -> 122 | send_method_not_allowed(Req, "GET,HEAD,POST,DELETE"). 123 | 124 | auth_name(String) when is_list(String) -> 125 | [_,_,_,_,_,Name|_] = re:split(String, "[\\W_]", [{return, list}]), 126 | ?l2b(Name). 127 | 128 | redirect_or_default(Req, RedirectHeaderKey, {_DefaultCode, DefaultHeaders} = Default) -> 129 | case couch_httpd:qs_value(Req, RedirectHeaderKey, nil) of 130 | nil -> Default; 131 | Redirect -> 132 | {302, DefaultHeaders ++ {"Location", couch_httpd:absolute_uri(Req, Redirect)}} 133 | end. 134 | 135 | ensure_cookie_auth_secret() -> 136 | case couch_config:get("couch_httpd_auth", "secret", nil) of 137 | nil -> 138 | NewSecret = ?b2l(couch_uuids:random()), 139 | couch_config:set("couch_httpd_auth", "secret", NewSecret), 140 | NewSecret; 141 | Secret -> Secret 142 | end. 143 | 144 | make_cookie_time() -> 145 | {NowMS, NowS, _} = erlang:now(), 146 | NowMS * 1000000 + NowS. 147 | 148 | cookie_scheme(#httpd{mochi_req=MochiReq}) -> 149 | [{http_only, true}] ++ 150 | case MochiReq:get(scheme) of 151 | http -> []; 152 | https -> [{secure, true}] 153 | end. 154 | 155 | cookie_auth_cookie(Req, User, Secret, TimeStamp) -> 156 | SessionData = User ++ ":" ++ erlang:integer_to_list(TimeStamp, 16), 157 | Hash = crypto:hmac(sha, Secret, SessionData), 158 | mochiweb_cookies:cookie("AuthSession", 159 | couch_util:encodeBase64Url(SessionData ++ ":" ++ ?b2l(Hash)), 160 | [{path, "/"}] ++ cookie_scheme(Req) ++ max_age()). 161 | 162 | max_age() -> 163 | case couch_config:get("couch_httpd_auth", "allow_persistent_cookies", "false") of 164 | "false" -> 165 | []; 166 | "true" -> 167 | Timeout = list_to_integer( 168 | couch_config:get("couch_httpd_auth", "timeout", "600")), 169 | [{max_age, Timeout}] 170 | end. 171 | 172 | get_auth_info(#httpd{ user_ctx = #user_ctx { handler = Handler } }) -> 173 | [ 174 | {authentication_db, ?l2b(couch_config:get("couch_httpd_auth", "authentication_db"))}, 175 | {authentication_handlers, [auth_name(H) || H <- couch_httpd:make_fun_spec_strs( 176 | couch_config:get("httpd", "authentication_handlers"))]} 177 | ] ++ 178 | case Handler of 179 | undefined -> []; 180 | Handler -> [{ authenticated, auth_name(?b2l(Handler)) }] 181 | end. 182 | 183 | get_req_credentials(#httpd{method='POST', mochi_req=MochiReq}) -> 184 | ReqBody = MochiReq:recv_body(), 185 | Form = case MochiReq:get_primary_header_value("content-type") of 186 | % content type should be json 187 | "application/x-www-form-urlencoded" ++ _ -> 188 | mochiweb_util:parse_qs(ReqBody); 189 | "application/json" ++ _ -> 190 | {Pairs} = ?JSON_DECODE(ReqBody), 191 | [{?b2l(Key), ?b2l(Value)} || {Key, Value} <- Pairs]; 192 | _ -> 193 | [] 194 | end, 195 | UserName = ?l2b(couch_util:get_value("name", Form, "")), 196 | Password = ?l2b(couch_util:get_value("password", Form, "")), 197 | {UserName, Password}. 198 | 199 | set_user_roles(UserName, Roles) -> 200 | ?LOG_INFO("Assigning user ~s roles: ~p", [UserName, Roles]), 201 | 202 | DbName = ?l2b(couch_config:get("couch_httpd_auth", "authentication_db")), 203 | DbOptions = [{user_ctx, #user_ctx{roles = [<<"_admin">>]}}], 204 | {ok, AuthDb} = couch_db:open_int(DbName, DbOptions), 205 | 206 | DocId = <<<<"org.couchdb.user:">>/binary, UserName/binary>>, 207 | Doc = case couch_db:open_doc(AuthDb, DocId, [ejson_body]) of 208 | {ok, OldDoc = #doc{body = {DocBody}}} -> 209 | OldDoc#doc{ 210 | body = {?replace(DocBody, <<"roles">>, Roles)} 211 | }; 212 | {not_found, _} -> 213 | #doc{ 214 | id = DocId, 215 | body = {[ 216 | {'_id', DocId}, 217 | {type, <<"user">>}, 218 | {name, UserName}, 219 | {salt, couch_uuids:random()}, 220 | {roles, Roles} 221 | ]} 222 | } 223 | end, 224 | 225 | ?LOG_INFO("Assigning _users/~s roles ~p", [DocId, Roles]), 226 | 227 | % disable validation so we can put _admin in the _users db. 228 | case couch_db:update_doc(AuthDb#db{ validate_doc_funs=[] }, Doc, []) of 229 | {ok, _} -> ok; 230 | {error, _} = Error -> throw(Error) 231 | end. 232 | 233 | authenticate_user(_UserName, _Password) when _UserName == <<"">>; _Password == <<"">> -> 234 | {error, missing_user_name_or_password}; 235 | authenticate_user(UserName, Password) -> 236 | ?LOG_INFO("Authenticating user: ~p", [UserName]), 237 | case connect() of 238 | {error, Reason} = Error -> 239 | ?LOG_ERROR("Could not connect to LDAP. Reason: ~p", [Reason]), 240 | Error; 241 | {ok, LdapConnection} -> 242 | case authenticate(LdapConnection, UserName, Password) of 243 | {error, Reason} = Error -> 244 | ?LOG_ERROR("Could not authenticate user ~p over LDAP. Reason: ~p", [UserName, Reason]), 245 | Error; 246 | {ok, UserDN} -> 247 | Groups = get_group_memberships(LdapConnection, UserDN), 248 | eldap:close(LdapConnection), 249 | {ok, [ ?l2b(string:to_lower(G)) || G <- Groups ]} 250 | end 251 | end. 252 | 253 | basic_name_pw(Req) -> 254 | AuthorizationHeader = couch_httpd:header_value(Req, "Authorization"), 255 | case AuthorizationHeader of 256 | "Basic " ++ Base64Value -> 257 | case re:split(base64:decode(Base64Value), ":", 258 | [{return, list}, {parts, 2}]) of 259 | ["_", "_"] -> 260 | % special name and pass to be logged out 261 | nil; 262 | [User, Pass] -> 263 | {User, Pass}; 264 | _ -> 265 | nil 266 | end; 267 | _ -> 268 | ?LOG_INFO("Could not recognize auth header ~p", [AuthorizationHeader]), 269 | nil 270 | end. 271 | -------------------------------------------------------------------------------- /src/ldap_auth_config.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author dmoore 3 | %%% @copyright (C) 2013, 4 | %%% @doc 5 | %%% 6 | %%% @end 7 | %%% Created : 08. Oct 2013 10:25 PM 8 | %%%------------------------------------------------------------------- 9 | -module(ldap_auth_config). 10 | -author("dmoore"). 11 | 12 | %% API 13 | -export([get_config/1]). 14 | -include("couch_db.hrl"). 15 | 16 | get_config([]) -> []; 17 | get_config([Key|Rem]) -> 18 | [case couch_config:get("ldap_auth", Key, undefined) of 19 | undefined -> throw({config_key_not_found, "Key not found in [ldap_auth] section of config: " ++ Key}); 20 | Value -> Value 21 | end | get_config(Rem)]. 22 | -------------------------------------------------------------------------------- /src/ldap_auth_gateway.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author dmoore 3 | %%% @copyright (C) 2013, 4 | %%% @doc 5 | %%%%%% @end 6 | %%% Created : 08. Oct 2013 10:21 PM 7 | %%%------------------------------------------------------------------- 8 | -module(ldap_auth_gateway). 9 | -author("dmoore"). 10 | 11 | -include_lib("eldap/include/eldap.hrl"). 12 | -include("couch_db.hrl"). 13 | 14 | %% API 15 | -export([connect/0, authenticate/3, get_user_dn/2, get_group_memberships/2]). 16 | 17 | -import(ldap_auth_config, [get_config/1]). 18 | 19 | authenticate(LdapConnection, User, Password) -> 20 | case get_user_dn(LdapConnection, User) of 21 | {error, _} = Error -> Error; 22 | {ok, UserDN} -> 23 | % attempt to connect as UserDN and if it doesn't throw, immediately disconnect. 24 | case connect(UserDN, Password) of 25 | {ok, UserLdapConnection} -> 26 | eldap:close(UserLdapConnection), 27 | { ok, UserDN }; 28 | {error, _} = Error -> 29 | ?LOG_INFO("Could authenticate user ~p with given password.", [User]), 30 | Error 31 | end 32 | end. 33 | 34 | get_user_dn(LdapConnection, User) when User =/= <<"">>, User =/= "" -> 35 | [UserDNMapAttr] = get_config(["UserDNMapAttr"]), 36 | 37 | case query(LdapConnection, "person", eldap:equalityMatch(UserDNMapAttr, User)) of 38 | [] -> 39 | ?LOG_INFO("Could not find user with ~s = ~p to authenticate.", [UserDNMapAttr, User]), 40 | { error, invalid_credentials }; 41 | [#eldap_entry{ object_name = UserDN } | _] -> 42 | {ok, UserDN} 43 | end. 44 | 45 | connect() -> 46 | [SearchUserDN, SearchUserPassword] = get_config(["SearchUserDN", "SearchUserPassword"]), 47 | connect(SearchUserDN, SearchUserPassword). 48 | 49 | connect(DN, Password) -> 50 | [LdapServers, UseSsl] = get_config(["LdapServers", "UseSsl"]), 51 | LdapServerList = re:split(LdapServers, "\\s*,\\s*", [{return, list}]), 52 | case eldap:open(LdapServerList, [{ssl, list_to_atom(UseSsl)}]) of 53 | {error, Reason} -> throw({ ldap_connection_error, Reason }); 54 | {ok, LdapConnection} -> 55 | case eldap:simple_bind(LdapConnection, DN, Password) of 56 | {error, _} -> 57 | eldap:close(LdapConnection), 58 | { error, invalid_credentials }; 59 | ok -> { ok, LdapConnection } 60 | end 61 | end. 62 | 63 | query(LdapConnection, Type, Filter) -> 64 | [BaseDN] = get_config(["BaseDN"]), 65 | TypedFilter = eldap:'and'([eldap:equalityMatch("objectClass", Type), Filter]), 66 | case eldap:search(LdapConnection, [{ base, BaseDN }, { filter, TypedFilter }]) of 67 | {error, Reason} -> throw({search, Reason}); 68 | {ok, #eldap_search_result{ entries = Result }} -> Result 69 | end. 70 | 71 | get_group_memberships(LdapConnection, UserDN) -> 72 | Memberships = get_group_memberships(LdapConnection, sets:new(), UserDN), 73 | [ R || {_, R} <- sets:to_list(Memberships)]. 74 | 75 | get_group_memberships(LdapConnection, Memberships, DN) -> 76 | [GroupDNMapAttr] = get_config(["GroupDNMapAttr"]), 77 | case query(LdapConnection, "group", eldap:equalityMatch("member", DN)) of 78 | [] -> Memberships; 79 | Entries -> 80 | ParentGroupDNs = [ 81 | case element(2, lists:keyfind(GroupDNMapAttr, 1, X#eldap_entry.attributes)) of 82 | [Value|_] -> {X#eldap_entry.object_name, Value}; 83 | _ -> throw({no_value}) 84 | end || X <- Entries 85 | ], 86 | S = sets:subtract(sets:from_list(ParentGroupDNs), Memberships), 87 | case sets:size(S) of 88 | 0 -> Memberships; 89 | _ -> sets:fold(fun ({N, _}, P) -> get_group_memberships(LdapConnection, P, N) end, sets:union(Memberships, S), S) 90 | end 91 | end. 92 | -------------------------------------------------------------------------------- /test/config_tests.erl: -------------------------------------------------------------------------------- 1 | -module(config_tests). 2 | 3 | -include("couch_db.hrl"). 4 | -include_lib("eunit/include/eunit.hrl"). 5 | -import(ldap_auth_config, [get_config/1]). 6 | 7 | found_1_test_() -> run([ 8 | ?_assertEqual(get_config(["foo"]), ["oof"]), 9 | ?_assertEqual(get_config(["bar"]), ["rab"]) 10 | ]). 11 | 12 | not_found_1_test_() -> run([ 13 | ?_assertThrow({config_key_not_found, _}, get_config(["does_not_exist"])) 14 | ]). 15 | 16 | found_2_test_() -> run([ 17 | ?_assertEqual(get_config(["foo", "bar"]), ["oof", "rab"]), 18 | ?_assertEqual(get_config(["bar", "foo"]), ["rab", "oof"]) 19 | ]). 20 | 21 | not_found_2_test_() -> run([ 22 | ?_assertThrow({config_key_not_found, _}, get_config(["does_not_exist", "foo", "bar"])), 23 | ?_assertThrow({config_key_not_found, _}, get_config(["foo", "does_not_exist", "bar"])), 24 | ?_assertThrow({config_key_not_found, _}, get_config(["foo", "bar", "does_not_exist"])) 25 | ]). 26 | 27 | run(Tests) -> 28 | { 29 | setup, 30 | fun () -> 31 | meck:new(couch_config, [non_strict]), 32 | meck:expect(couch_config, get, 33 | fun ("ldap_auth", "foo", _) -> "oof"; 34 | ("ldap_auth", "bar", _) -> "rab"; 35 | ("ldap_auth", _, NotFound) -> NotFound 36 | end) 37 | end, 38 | fun (_) -> meck:unload(couch_config) end, 39 | fun (_) -> Tests end 40 | }. -------------------------------------------------------------------------------- /test/ldap_integration_tests.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author dmoore 3 | %%% @copyright (C) 2013, 4 | %%% @doc 5 | %%% 6 | %%% @end 7 | %%% Created : 19. Oct 2013 7:45 PM 8 | %%%------------------------------------------------------------------- 9 | -module(ldap_integration_tests). 10 | -author("dmoore"). 11 | 12 | -include("couch_db.hrl"). 13 | -include_lib("eunit/include/eunit.hrl"). 14 | 15 | -define(TEST_USER, "test.npm"). 16 | -define(TEST_USER_PASSWORD, "T32!11pm"). 17 | 18 | integration_test_() -> io:format("Testing...", []), run([fun () -> 19 | {ok, LdapConnection} = ldap_auth_gateway:connect(), 20 | io:format("Connect OK"), 21 | {ok, UserDN} = ldap_auth_gateway:authenticate(LdapConnection, ?TEST_USER, ?TEST_USER_PASSWORD), 22 | io:format("UserDN=~p\n", [UserDN]), 23 | Groups = ldap_auth_gateway:get_group_memberships(LdapConnection, UserDN), 24 | io:format("Groups=~p\n", [Groups]), 25 | eldap:close(LdapConnection) 26 | end]). 27 | 28 | run(Tests) -> 29 | { 30 | setup, 31 | fun () -> 32 | meck:new(couch_config, [non_strict]), 33 | meck:expect(couch_config, get, fun test_config:get_config/3) 34 | end, 35 | fun (_) -> meck:unload(couch_config) end, 36 | fun (_) -> Tests end 37 | }. -------------------------------------------------------------------------------- /test/test_config.erl: -------------------------------------------------------------------------------- 1 | -module(test_config). 2 | 3 | -export([get_config/3]). 4 | 5 | get_config("ldap_auth", "UseSsl", _) -> "false"; 6 | get_config("ldap_auth", "LdapServer", _) -> "atlas.northhorizon.local"; 7 | get_config("ldap_auth", "BaseDN", _) -> "DC=northhorizon,DC=local"; 8 | get_config("ldap_auth", "SearchUserDN", _) -> "CN=ldapsearch,CN=Users,DC=northhorizon,DC=local"; 9 | get_config("ldap_auth", "SearchUserPassword", _) -> "Welcome1"; 10 | get_config("ldap_auth", "UserDNMapAttr", _) -> "sAMAccountName"; 11 | get_config("ldap_auth", "GroupDNMapAttr", _) -> "name"; 12 | get_config("ldap_auth", _, NotFound) -> NotFound. 13 | --------------------------------------------------------------------------------