summaryrefslogtreecommitdiff
path: root/src/couchdb/couch_httpd_auth.erl
diff options
context:
space:
mode:
Diffstat (limited to 'src/couchdb/couch_httpd_auth.erl')
-rw-r--r--src/couchdb/couch_httpd_auth.erl507
1 files changed, 507 insertions, 0 deletions
diff --git a/src/couchdb/couch_httpd_auth.erl b/src/couchdb/couch_httpd_auth.erl
new file mode 100644
index 00000000..29a50eb5
--- /dev/null
+++ b/src/couchdb/couch_httpd_auth.erl
@@ -0,0 +1,507 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_httpd_auth).
+-include("couch_db.hrl").
+
+-export([default_authentication_handler/1,special_test_authentication_handler/1]).
+-export([cookie_authentication_handler/1]).
+-export([null_authentication_handler/1]).
+-export([cookie_auth_header/2]).
+-export([handle_session_req/1]).
+-export([handle_user_req/1]).
+-export([ensure_users_db_exists/1, get_user/2]).
+
+-import(couch_httpd, [header_value/2, send_json/2,send_json/4, send_method_not_allowed/2]).
+
+special_test_authentication_handler(Req) ->
+ case header_value(Req, "WWW-Authenticate") of
+ "X-Couch-Test-Auth " ++ NamePass ->
+ % NamePass is a colon separated string: "joe schmoe:a password".
+ [Name, Pass] = re:split(NamePass, ":", [{return, list}]),
+ case {Name, Pass} of
+ {"Jan Lehnardt", "apple"} -> ok;
+ {"Christopher Lenz", "dog food"} -> ok;
+ {"Noah Slater", "biggiesmalls endian"} -> ok;
+ {"Chris Anderson", "mp3"} -> ok;
+ {"Damien Katz", "pecan pie"} -> ok;
+ {_, _} ->
+ throw({unauthorized, <<"Name or password is incorrect.">>})
+ end,
+ Req#httpd{user_ctx=#user_ctx{name=?l2b(Name)}};
+ _ ->
+ % No X-Couch-Test-Auth credentials sent, give admin access so the
+ % previous authentication can be restored after the test
+ Req#httpd{user_ctx=#user_ctx{roles=[<<"_admin">>]}}
+ end.
+
+basic_username_pw(Req) ->
+ case header_value(Req, "Authorization") of
+ "Basic " ++ Base64Value ->
+ case string:tokens(?b2l(couch_util:decodeBase64(Base64Value)),":") of
+ [User, Pass] ->
+ {User, Pass};
+ [User] ->
+ {User, ""};
+ _ ->
+ nil
+ end;
+ _ ->
+ nil
+ end.
+
+default_authentication_handler(Req) ->
+ case basic_username_pw(Req) of
+ {User, Pass} ->
+ case couch_server:is_admin(User, Pass) of
+ true ->
+ Req#httpd{user_ctx=#user_ctx{name=?l2b(User), roles=[<<"_admin">>]}};
+ false ->
+ throw({unauthorized, <<"Name or password is incorrect.">>})
+ end;
+ nil ->
+ case couch_server:has_admins() of
+ true ->
+ Req;
+ false ->
+ case couch_config:get("couch_httpd_auth", "require_valid_user", "false") of
+ "true" -> Req;
+ % If no admins, and no user required, then everyone is admin!
+ % Yay, admin party!
+ _ -> Req#httpd{user_ctx=#user_ctx{roles=[<<"_admin">>]}}
+ end
+ end
+ end.
+
+null_authentication_handler(Req) ->
+ Req#httpd{user_ctx=#user_ctx{roles=[<<"_admin">>]}}.
+
+% Cookie auth handler using per-node user db
+cookie_authentication_handler(Req) ->
+ DbName = couch_config:get("couch_httpd_auth", "authentication_db"),
+ case cookie_auth_user(Req, ?l2b(DbName)) of
+ % Fall back to default authentication handler
+ nil -> default_authentication_handler(Req);
+ Req2 -> Req2
+ end.
+
+% Cookie auth handler using per-db user db
+% cookie_authentication_handler(#httpd{path_parts=Path}=Req) ->
+% case Path of
+% [DbName|_] ->
+% case cookie_auth_user(Req, DbName) of
+% nil -> default_authentication_handler(Req);
+% Req2 -> Req2
+% end;
+% _Else ->
+% % Fall back to default authentication handler
+% default_authentication_handler(Req)
+% end.
+
+% maybe we can use hovercraft to simplify running this view query
+get_user(Db, UserName) ->
+ DesignId = <<"_design/_auth">>,
+ ViewName = <<"users">>,
+ % if the design doc or the view doesn't exist, then make it
+ ensure_users_view_exists(Db, DesignId, ViewName),
+
+ case (catch couch_view:get_map_view(Db, DesignId, ViewName, nil)) of
+ {ok, View, _Group} ->
+ FoldlFun = fun
+ ({{Key, _DocId}, Value}, _, nil) when Key == UserName -> {ok, Value};
+ (_, _, Acc) -> {stop, Acc}
+ end,
+ case couch_view:fold(View, {UserName, nil}, fwd, FoldlFun, nil) of
+ {ok, {Result}} -> Result;
+ _Else -> nil
+ end;
+ {not_found, _Reason} ->
+ nil
+ % case (catch couch_view:get_reduce_view(Db, DesignId, ViewName, nil)) of
+ % {ok, _ReduceView, _Group} ->
+ % not_implemented;
+ % {not_found, _Reason} ->
+ % nil
+ % end
+ end.
+
+ensure_users_db_exists(DbName) ->
+ case couch_db:open(DbName, [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of
+ {ok, Db} ->
+ couch_db:close(Db),
+ ok;
+ _Error ->
+ ?LOG_ERROR("Create the db ~p", [DbName]),
+ {ok, Db} = couch_db:create(DbName, [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]),
+ ?LOG_ERROR("Created the db ~p", [DbName]),
+ couch_db:close(Db),
+ ok
+ end.
+
+ensure_users_view_exists(Db, DDocId, VName) ->
+ try couch_httpd_db:couch_doc_open(Db, DDocId, nil, []) of
+ _Foo -> ok
+ catch
+ _:Error ->
+ ?LOG_ERROR("create the design document ~p : ~p", [DDocId, Error]),
+ % create the design document
+ {ok, AuthDesign} = auth_design_doc(DDocId, VName),
+ {ok, _Rev} = couch_db:update_doc(Db, AuthDesign, []),
+ ?LOG_ERROR("created the design document", []),
+ ok
+ end.
+
+auth_design_doc(DocId, VName) ->
+ DocProps = [
+ {<<"_id">>, DocId},
+ {<<"language">>,<<"javascript">>},
+ {<<"views">>,
+ {[{VName,
+ {[{<<"map">>,
+ <<"function (doc) {\n if (doc.type == \"user\") {\n emit(doc.username, doc);\n}\n}">>
+ }]}
+ }]}
+ }],
+ {ok, couch_doc:from_json_obj({DocProps})}.
+
+
+user_doc(DocId, Username, UserSalt, PasswordHash, Email, Active, Roles) ->
+ user_doc(DocId, Username, UserSalt, PasswordHash, Email, Active, Roles, nil).
+user_doc(DocId, Username, UserSalt, PasswordHash, Email, Active, Roles, Rev) ->
+ DocProps = [
+ {<<"_id">>, DocId},
+ {<<"type">>, <<"user">>},
+ {<<"username">>, Username},
+ {<<"password_sha">>, PasswordHash},
+ {<<"salt">>, UserSalt},
+ {<<"email">>, Email},
+ {<<"active">>, Active},
+ {<<"roles">>, Roles}],
+ DocProps1 = case Rev of
+ nil -> DocProps;
+ _Rev ->
+ [{<<"_rev">>, Rev}] ++ DocProps
+ end,
+ {ok, couch_doc:from_json_obj({DocProps1})}.
+
+cookie_auth_user(_Req, undefined) -> nil;
+cookie_auth_user(#httpd{mochi_req=MochiReq}=Req, DbName) ->
+ case MochiReq:get_cookie_value("AuthSession") of
+ undefined -> nil;
+ [] -> nil;
+ Cookie ->
+ case couch_db:open(DbName, [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of
+ {ok, Db} ->
+ try
+ AuthSession = couch_util:decodeBase64Url(Cookie),
+ [User, TimeStr | HashParts] = string:tokens(?b2l(AuthSession), ":"),
+ % Verify expiry and hash
+ {NowMS, NowS, _} = erlang:now(),
+ CurrentTime = NowMS * 1000000 + NowS,
+ case couch_config:get("couch_httpd_auth", "secret", nil) of
+ nil -> nil;
+ SecretStr ->
+ Secret = ?l2b(SecretStr),
+ case get_user(Db, ?l2b(User)) of
+ nil -> nil;
+ Result ->
+ UserSalt = proplists:get_value(<<"salt">>, Result, <<"">>),
+ FullSecret = <<Secret/binary, UserSalt/binary>>,
+ ExpectedHash = crypto:sha_mac(FullSecret, User ++ ":" ++ TimeStr),
+ Hash = ?l2b(string:join(HashParts, ":")),
+ Timeout = to_int(couch_config:get("couch_httpd_auth", "timeout", 600)),
+ ?LOG_DEBUG("timeout ~p", [Timeout]),
+ case (catch erlang:list_to_integer(TimeStr, 16)) of
+ TimeStamp when CurrentTime < TimeStamp + Timeout
+ andalso ExpectedHash == Hash ->
+ TimeLeft = TimeStamp + Timeout - CurrentTime,
+ ?LOG_DEBUG("Successful cookie auth as: ~p", [User]),
+ Req#httpd{user_ctx=#user_ctx{
+ name=?l2b(User),
+ roles=proplists:get_value(<<"roles">>, Result, [])
+ }, auth={FullSecret, TimeLeft < Timeout*0.9}};
+ _Else ->
+ nil
+ end
+ end
+ end
+ after
+ couch_db:close(Db)
+ end;
+ _Else ->
+ nil
+ end
+ end.
+
+cookie_auth_header(#httpd{user_ctx=#user_ctx{name=null}}, _Headers) -> [];
+cookie_auth_header(#httpd{user_ctx=#user_ctx{name=User}, auth={Secret, true}}, Headers) ->
+ % Note: we only set the AuthSession cookie if:
+ % * a valid AuthSession cookie has been received
+ % * we are outside a 10% timeout window
+ % * and if an AuthSession cookie hasn't already been set e.g. by a login
+ % or logout handler.
+ % The login and logout handlers need to set the AuthSession cookie
+ % themselves.
+ case proplists:get_value("Set-Cookie", Headers) of
+ undefined -> [];
+ Cookie ->
+ case proplists:get_value("AuthSession",
+ mochiweb_cookies:parse_cookie(Cookie), undefined) of
+ undefined ->
+ {NowMS, NowS, _} = erlang:now(),
+ TimeStamp = NowMS * 1000000 + NowS,
+ [cookie_auth_cookie(?b2l(User), Secret, TimeStamp)];
+ _Else -> []
+ end
+ end;
+cookie_auth_header(_Req, _Headers) -> [].
+
+cookie_auth_cookie(User, Secret, TimeStamp) ->
+ SessionData = User ++ ":" ++ erlang:integer_to_list(TimeStamp, 16),
+ Hash = crypto:sha_mac(Secret, SessionData),
+ mochiweb_cookies:cookie("AuthSession",
+ couch_util:encodeBase64Url(SessionData ++ ":" ++ ?b2l(Hash)),
+ [{path, "/"}, {http_only, true}]). % TODO add {secure, true} when SSL is detected
+
+% Login handler with user db
+handle_login_req(#httpd{method='POST', mochi_req=MochiReq}=Req, #db{}=Db) ->
+ ReqBody = MochiReq:recv_body(),
+ Form = case MochiReq:get_primary_header_value("content-type") of
+ "application/x-www-form-urlencoded" ++ _ ->
+ mochiweb_util:parse_qs(ReqBody);
+ _ ->
+ []
+ end,
+ UserName = ?l2b(proplists:get_value("username", Form, "")),
+ Password = ?l2b(proplists:get_value("password", Form, "")),
+ User = case get_user(Db, UserName) of
+ nil -> [];
+ Result -> Result
+ end,
+ UserSalt = proplists:get_value(<<"salt">>, User, <<>>),
+ PasswordHash = couch_util:encodeBase64(crypto:sha(<<UserSalt/binary, Password/binary>>)),
+ case proplists:get_value(<<"password_sha">>, User, nil) of
+ ExpectedHash when ExpectedHash == PasswordHash ->
+ Secret = ?l2b(couch_config:get("couch_httpd_auth", "secret", nil)),
+ {NowMS, NowS, _} = erlang:now(),
+ CurrentTime = NowMS * 1000000 + NowS,
+ Cookie = cookie_auth_cookie(?b2l(UserName), <<Secret/binary, UserSalt/binary>>, CurrentTime),
+ {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of
+ nil ->
+ {200, [Cookie]};
+ Redirect ->
+ {302, [Cookie, {"Location", couch_httpd:absolute_uri(Req, Redirect)}]}
+ end,
+ send_json(Req#httpd{req_body=ReqBody}, Code, Headers,
+ {[{ok, true}]});
+ _Else ->
+ throw({unauthorized, <<"Name or password is incorrect.">>})
+ end.
+
+% Session Handler
+
+handle_session_req(#httpd{method='POST'}=Req) ->
+ % login
+ DbName = couch_config:get("couch_httpd_auth", "authentication_db"),
+ case couch_db:open(?l2b(DbName), [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of
+ {ok, Db} -> handle_login_req(Req, Db)
+ end;
+handle_session_req(#httpd{method='GET', user_ctx=UserCtx}=Req) ->
+ % whoami
+ Name = UserCtx#user_ctx.name,
+ Roles = UserCtx#user_ctx.roles,
+ ForceLogin = couch_httpd:qs_value(Req, "basic", "false"),
+ case {Name, ForceLogin} of
+ {null, "true"} ->
+ throw({unauthorized, <<"Please login.">>});
+ _False -> ok
+ end,
+ send_json(Req, {[
+ {ok, true},
+ {name, Name},
+ {roles, Roles}
+ ]});
+handle_session_req(#httpd{method='DELETE'}=Req) ->
+ % logout
+ Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}, {http_only, true}]),
+ {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of
+ nil ->
+ {200, [Cookie]};
+ Redirect ->
+ {302, [Cookie, {"Location", couch_httpd:absolute_uri(Req, Redirect)}]}
+ end,
+ send_json(Req, Code, Headers, {[{ok, true}]});
+handle_session_req(Req) ->
+ send_method_not_allowed(Req, "GET,HEAD,POST,DELETE").
+
+create_user_req(#httpd{method='POST', mochi_req=MochiReq}=Req, Db) ->
+ ReqBody = MochiReq:recv_body(),
+ Form = case MochiReq:get_primary_header_value("content-type") of
+ "application/x-www-form-urlencoded" ++ _ ->
+ ?LOG_INFO("body parsed ~p", [mochiweb_util:parse_qs(ReqBody)]),
+ mochiweb_util:parse_qs(ReqBody);
+ _ ->
+ []
+ end,
+ Roles = proplists:get_all_values("roles", Form),
+ UserName = ?l2b(proplists:get_value("username", Form, "")),
+ Password = ?l2b(proplists:get_value("password", Form, "")),
+ Email = ?l2b(proplists:get_value("email", Form, "")),
+ Active = couch_httpd_view:parse_bool_param(proplists:get_value("active", Form, "true")),
+ case get_user(Db, UserName) of
+ nil ->
+ Roles1 = case Roles of
+ [] -> Roles;
+ _ ->
+ ok = couch_httpd:verify_is_server_admin(Req),
+ [?l2b(R) || R <- Roles]
+ end,
+
+ UserSalt = couch_util:new_uuid(),
+ PasswordHash = couch_util:encodeBase64(crypto:sha(<<UserSalt/binary, Password/binary>>)),
+ DocId = couch_util:new_uuid(),
+ {ok, UserDoc} = user_doc(DocId, UserName, UserSalt, PasswordHash, Email, Active, Roles1),
+ {ok, _Rev} = couch_db:update_doc(Db, UserDoc, []),
+ ?LOG_DEBUG("User ~s (~s) with password, ~s created.", [?b2l(UserName), ?b2l(DocId), ?b2l(Password)]),
+ {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of
+ nil ->
+ {200, []};
+ Redirect ->
+ {302, [{"Location", couch_httpd:absolute_uri(Req, Redirect)}]}
+ end,
+ send_json(Req, Code, Headers, {[{ok, true}]});
+ _Result ->
+ ?LOG_DEBUG("Can't create ~s: already exists", [?b2l(UserName)]),
+ throw({forbidden, <<"User already exists.">>})
+ end.
+
+update_user_req(#httpd{method='PUT', mochi_req=MochiReq, user_ctx=UserCtx}=Req, Db, UserName) ->
+ Name = UserCtx#user_ctx.name,
+ UserRoles = UserCtx#user_ctx.roles,
+ case User = get_user(Db, UserName) of
+ nil ->
+ throw({not_found, <<"User don't exist">>});
+ _Result ->
+ ReqBody = MochiReq:recv_body(),
+ Form = case MochiReq:get_primary_header_value("content-type") of
+ "application/x-www-form-urlencoded" ++ _ ->
+ mochiweb_util:parse_qs(ReqBody);
+ _ ->
+ []
+ end,
+ Roles = proplists:get_all_values("roles", Form),
+ Password = ?l2b(proplists:get_value("password", Form, "")),
+ Email = ?l2b(proplists:get_value("email", Form, "")),
+ Active = couch_httpd_view:parse_bool_param(proplists:get_value("active", Form, "true")),
+ OldPassword = proplists:get_value("old_password", Form, ""),
+ OldPassword1 = ?l2b(OldPassword),
+ UserSalt = proplists:get_value(<<"salt">>, User, <<>>),
+ OldRev = proplists:get_value(<<"_rev">>, User, <<>>),
+ DocId = proplists:get_value(<<"_id">>, User, <<>>),
+ CurrentPasswordHash = proplists:get_value(<<"password_sha">>, User, nil),
+
+
+ Roles1 = case Roles of
+ [] -> Roles;
+ _ ->
+ ok = couch_httpd:verify_is_server_admin(Req),
+ [?l2b(R) || R <- Roles]
+ end,
+
+ PasswordHash = case lists:member(<<"_admin">>, UserRoles) of
+ true ->
+ Hash = case Password of
+ <<>> -> CurrentPasswordHash;
+ _Else ->
+ H = couch_util:encodeBase64(crypto:sha(<<UserSalt/binary, Password/binary>>)),
+ H
+ end,
+ Hash;
+ false when Name == UserName ->
+ %% for user we test old password before allowing change
+ Hash = case Password of
+ <<>> ->
+ CurrentPasswordHash;
+ _P when length(OldPassword) == 0 ->
+ throw({forbidden, <<"Old password is incorrect.">>});
+ _Else ->
+ OldPasswordHash = couch_util:encodeBase64(crypto:sha(<<UserSalt/binary, OldPassword1/binary>>)),
+ ?LOG_DEBUG("~p == ~p", [CurrentPasswordHash, OldPasswordHash]),
+ Hash1 = case CurrentPasswordHash of
+ ExpectedHash when ExpectedHash == OldPasswordHash ->
+ H = couch_util:encodeBase64(crypto:sha(<<UserSalt/binary, Password/binary>>)),
+ H;
+ _ ->
+ throw({forbidden, <<"Old password is incorrect.">>})
+ end,
+ Hash1
+ end,
+ Hash;
+ _ ->
+ throw({forbidden, <<"You aren't allowed to change this password.">>})
+ end,
+ {ok, UserDoc} = user_doc(DocId, UserName, UserSalt, PasswordHash, Email, Active, Roles1, OldRev),
+ {ok, _Rev} = couch_db:update_doc(Db, UserDoc, []),
+ ?LOG_DEBUG("User ~s (~s)updated.", [?b2l(UserName), ?b2l(DocId)]),
+ {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of
+ nil -> {200, []};
+ Redirect ->
+ {302, [{"Location", couch_httpd:absolute_uri(Req, Redirect)}]}
+ end,
+ send_json(Req, Code, Headers, {[{ok, true}]})
+ end.
+
+handle_user_req(#httpd{method='POST'}=Req) ->
+ DbName = couch_config:get("couch_httpd_auth", "authentication_db"),
+ ensure_users_db_exists(?l2b(DbName)),
+ case couch_db:open(?l2b(DbName), [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of
+ {ok, Db} -> create_user_req(Req, Db)
+ end;
+handle_user_req(#httpd{method='PUT', path_parts=[_]}=_Req) ->
+ throw({bad_request, <<"Username is missing">>});
+handle_user_req(#httpd{method='PUT', path_parts=[_, UserName]}=Req) ->
+ DbName = couch_config:get("couch_httpd_auth", "authentication_db"),
+ ensure_users_db_exists(?l2b(DbName)),
+ case couch_db:open(?l2b(DbName), [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of
+ {ok, Db} -> update_user_req(Req, Db, UserName)
+ end;
+handle_user_req(Req) ->
+ send_method_not_allowed(Req, "GET,HEAD,POST,PUT,DELETE").
+
+to_int(Value) when is_binary(Value) ->
+ to_int(?b2l(Value));
+to_int(Value) when is_list(Value) ->
+ erlang:list_to_integer(Value);
+to_int(Value) when is_integer(Value) ->
+ Value.
+
+% % Login handler
+% handle_login_req(#httpd{method='POST'}=Req) ->
+% DbName = couch_config:get("couch_httpd_auth", "authentication_db"),
+% case couch_db:open(?l2b(DbName), [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}]) of
+% {ok, Db} -> handle_login_req(Req, Db)
+% end;
+% handle_login_req(Req) ->
+% send_method_not_allowed(Req, "POST").
+%
+% % Logout handler
+% handle_logout_req(#httpd{method='POST'}=Req) ->
+% Cookie = mochiweb_cookies:cookie("AuthSession", "", [{path, "/"}, {http_only, true}]),
+% {Code, Headers} = case couch_httpd:qs_value(Req, "next", nil) of
+% nil ->
+% {200, [Cookie]};
+% Redirect ->
+% {302, [Cookie, {"Location", couch_httpd:absolute_uri(Req, Redirect)}]}
+% end,
+% send_json(Req, Code, Headers, {[{ok, true}]});
+% handle_logout_req(Req) ->
+% send_method_not_allowed(Req, "POST").