% 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) -> AuthorizationHeader = header_value(Req, "Authorization"), case AuthorizationHeader of "Basic " ++ Base64Value -> io:format("~n~nBase64Value: '~p'~n~n", [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) -> % In the future this will be pluggable. For now we check the .ini first, % then fall back to querying the db. case couch_config:get("admins", ?b2l(UserName)) of "-hashed-" ++ HashedPwdAndSalt -> io:format("hashed: '~p'~n", [hashed]), [HashedPwd, Salt] = string:tokens(HashedPwdAndSalt, ","), [{<<"roles">>, [<<"_admin">>]}, {<<"salt">>, ?l2b(Salt)}, {<<"password_sha">>, ?l2b(HashedPwd)}]; _ -> 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 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 = <>, 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(<>)), 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), <>, 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(<>)), 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(<>)), 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(<>)), ?LOG_DEBUG("~p == ~p", [CurrentPasswordHash, OldPasswordHash]), Hash1 = case CurrentPasswordHash of ExpectedHash when ExpectedHash == OldPasswordHash -> H = couch_util:encodeBase64(crypto:sha(<>)), 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, "POST,PUT"). 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").