├── .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 |
--------------------------------------------------------------------------------