| % 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 -> |
| 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 = <<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. |
| CookieHeader = proplists:get_value("Set-Cookie", Headers, ""), |
| Cookies = mochiweb_cookies:parse_cookie(CookieHeader), |
| AuthSession = proplists:get_value("AuthSession", Cookies), |
| if AuthSession == undefined -> |
| {NowMS, NowS, _} = erlang:now(), |
| TimeStamp = NowMS * 1000000 + NowS, |
| [cookie_auth_cookie(?b2l(User), Secret, TimeStamp)]; |
| true -> |
| [] |
| 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 |
| |
| hash_password(Password, Salt) -> |
| ?l2b(couch_util:to_hex(crypto:sha(<<Password/binary, Salt/binary>>))). |
| |
| % 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 = hash_password(Password, UserSalt), |
| 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 = hash_password(Password, UserSalt), |
| 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 = hash_password(Password, UserSalt), |
| 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 = hash_password(OldPassword1, UserSalt), |
| ?LOG_DEBUG("~p == ~p", [CurrentPasswordHash, OldPasswordHash]), |
| Hash1 = case CurrentPasswordHash of |
| ExpectedHash when ExpectedHash == OldPasswordHash -> |
| H = hash_password(Password, UserSalt), |
| 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"). |