diff options
Diffstat (limited to 'src/chttpd_auth.erl')
-rw-r--r-- | src/chttpd_auth.erl | 481 |
1 files changed, 481 insertions, 0 deletions
diff --git a/src/chttpd_auth.erl b/src/chttpd_auth.erl new file mode 100644 index 00000000..3916f7cf --- /dev/null +++ b/src/chttpd_auth.erl @@ -0,0 +1,481 @@ +% 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(chttpd_auth). +-include("chttpd.hrl"). + +-export([special_test_authentication_handler/1, null_authentication_handler/1, + cookie_authentication_handler/1, default_authentication_handler/1, + handle_session_req/1, handle_user_req/1, cookie_auth_header/2]). + +% used by OAuth handler +-export([get_user/1, ensure_users_db_exists/1]). + +-import(chttpd, [send_json/2, send_json/4, send_method_not_allowed/2]). + +special_test_authentication_handler(Req) -> + case chttpd: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. + +null_authentication_handler(Req) -> + Ctx = #user_ctx{roles=[<<"_reader">>, <<"writer">>, <<"_admin">>]}, + Req#httpd{user_ctx=Ctx}. + +default_authentication_handler(Req) -> + case basic_username_pw(Req) of + {Username, Password} -> + case get_user(Username) of + nil -> + throw({unauthorized, <<"unknown username">>}); + Props -> + ExpectedHash = couch_util:get_value(<<"password_sha">>, Props), + Salt = couch_util:get_value(<<"salt">>, Props), + case hash_password(?l2b(Password), Salt) of + ExpectedHash -> + Ctx = #user_ctx{ + name = couch_util:get_value(<<"username">>, Props), + roles = couch_util:get_value(<<"roles">>, Props) + }, + Req#httpd{user_ctx=Ctx}; + _ -> + throw({unauthorized, <<"password is incorrect">>}) + end + end; + nil -> + Req + end. + +cookie_authentication_handler(#httpd{path_parts=[<<"_session">>], + method='POST'} = Req) -> + % ignore any cookies sent with login request + Req; +cookie_authentication_handler(Req) -> + case cookie_auth_user(Req) of + nil -> + Req; + cookie_auth_failed -> + put(cookie_auth_failed, true), + Req#httpd{auth=cookie_auth_failed}; + Req2 -> + Req2 + end. + +cookie_auth_header(#httpd{auth=cookie_auth_failed}, Headers) -> + % check for an AuthSession cookie from login handler + CookieHeader = couch_util:get_value("Set-Cookie", Headers, ""), + Cookies = mochiweb_cookies:parse_cookie(CookieHeader), + AuthSession = couch_util:get_value("AuthSession", Cookies), + if AuthSession == undefined -> + [generate_cookie_buster()]; + true -> + [] + end; +cookie_auth_header(#httpd{user_ctx=#user_ctx{name=null}}, _Headers) -> + []; +cookie_auth_header(#httpd{user_ctx=Ctx, 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 set the AuthSession cookie themselves. + CookieHeader = couch_util:get_value("Set-Cookie", Headers, ""), + Cookies = mochiweb_cookies:parse_cookie(CookieHeader), + AuthSession = couch_util:get_value("AuthSession", Cookies), + if AuthSession == undefined -> + [generate_cookie(Ctx#user_ctx.name, Secret, timestamp())]; + true -> + [] + end; +cookie_auth_header(Req, Headers) -> + case get(cookie_auth_failed) of + true -> + cookie_auth_header(Req#httpd{auth=cookie_auth_failed}, Headers); + _ -> + [] + end. + +handle_session_req(#httpd{method='POST', mochi_req=MochiReq, user_ctx=Ctx}=Req) -> + % login + Form = parse_form(MochiReq), + UserName = extract_username(Form), + case get_user(UserName) of + nil -> + throw({forbidden, <<"unknown username">>}); + User -> + UserSalt = couch_util:get_value(<<"salt">>, User), + case lists:member(<<"_admin">>, Ctx#user_ctx.roles) of + true -> + ok; + false -> + Password = extract_password(Form), + ExpectedHash = couch_util:get_value(<<"password_sha">>, User), + case hash_password(Password, UserSalt) of + ExpectedHash -> + ok; + _Else -> + throw({forbidden, <<"Name or password is incorrect.">>}) + end + end, + Secret = ?l2b(couch_config:get("chttpd_auth", "secret")), + SecretAndSalt = <<Secret/binary, UserSalt/binary>>, + Cookie = generate_cookie(UserName, SecretAndSalt, timestamp()), + send_response(Req, [Cookie]) + end; +handle_session_req(#httpd{method='GET', user_ctx=UserCtx}=Req) -> + % whoami + #user_ctx{name = Name, roles = Roles} = UserCtx, + ForceLogin = chttpd:qs_value(Req, "basic", "false"), + case {Name, ForceLogin} of + {null, "true"} -> + throw({unauthorized, <<"Please login.">>}); + _False -> + send_json(Req, {[{ok,true}, {name,Name}, {roles,Roles}]}) + end; +handle_session_req(#httpd{method='DELETE'}=Req) -> + % logout + send_response(Req, [generate_cookie_buster()]); +handle_session_req(Req) -> + send_method_not_allowed(Req, "GET,HEAD,POST,DELETE"). + +handle_user_req(#httpd{method='POST'}=Req) -> + DbName = couch_config:get("chttpd_auth", "authentication_db"), + {ok, Db} = ensure_users_db_exists(?l2b(DbName)), + Result = create_user(Req, Db), + couch_db:close(Db), + Result; +handle_user_req(#httpd{method=Method, path_parts=[_]}=_Req) when + Method == 'PUT' orelse Method == 'DELETE' -> + throw({bad_request, <<"Username is missing">>}); +handle_user_req(#httpd{method='PUT', path_parts=[_, UserName]}=Req) -> + DbName = couch_config:get("chttpd_auth", "authentication_db"), + {ok, Db} = ensure_users_db_exists(?l2b(DbName)), + Result = update_user(Req, Db, UserName), + couch_db:close(Db), + Result; +handle_user_req(#httpd{method='DELETE', path_parts=[_, UserName]}=Req) -> + DbName = couch_config:get("chttpd_auth", "authentication_db"), + {ok, Db} = ensure_users_db_exists(?l2b(DbName)), + Result = delete_user(Req, Db, UserName), + couch_db:close(Db), + Result; +handle_user_req(Req) -> + send_method_not_allowed(Req, "DELETE,POST,PUT"). + +get_user(UserName) when is_list(UserName) -> + get_user(?l2b(UserName)); +get_user(UserName) -> + case couch_config:get("admins", ?b2l(UserName)) of + "-hashed-" ++ HashedPwdAndSalt -> + [HashedPwd, Salt] = string:tokens(HashedPwdAndSalt, ","), + [ + {<<"username">>, UserName}, + {<<"roles">>, [<<"_reader">>, <<"_writer">>, <<"_admin">>]}, + {<<"salt">>, ?l2b(Salt)}, + {<<"password_sha">>, ?l2b(HashedPwd)} + ]; + _ -> + try ets:lookup(users_cache, UserName) of + [{UserName, Props}] -> + Props; + [] -> + load_user_from_db(UserName) + catch error:badarg -> + load_user_from_db(UserName) + end + end. + +load_user_from_db(UserName) -> + DbName = couch_config:get("chttpd_auth", "authentication_db"), + {ok, Db} = ensure_users_db_exists(?l2b(DbName)), + UserProps = case couch_db:open_doc(Db, UserName, []) of + {ok, Doc} -> + ?LOG_INFO("cache miss on username ~s", [UserName]), + {Props} = couch_doc:to_json_obj(Doc, []), + Props; + _Else -> + ?LOG_INFO("no record of user ~s", [UserName]), + nil + end, + couch_db:close(Db), + UserProps. + +ensure_users_db_exists(DbName) -> + Options = [{user_ctx, #user_ctx{roles=[<<"_admin">>]}}], + case couch_db:open(DbName, Options) of + {ok, Db} -> + {ok, Db}; + {error, _} -> + couch_db:create(DbName, Options) + end. + +% internal functions + +basic_username_pw(Req) -> + case chttpd: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. + +cookie_auth_user(#httpd{mochi_req=MochiReq}=Req) -> + case MochiReq:get_cookie_value("AuthSession") of + undefined -> + nil; + Cookie -> + AuthSession = couch_util:decodeBase64Url(Cookie), + [User, TimeStr | HashParts] = string:tokens(?b2l(AuthSession), ":"), + % Verify expiry and hash + case couch_config:get("chttpd_auth", "secret") of + undefined -> + ?LOG_DEBUG("AuthSession cookie, but no secret in config!", []), + cookie_auth_failed; + SecretStr -> + case get_user(User) of + nil -> + ?LOG_DEBUG("no record of user ~s", [User]), + cookie_auth_failed; + Result -> + Secret = ?l2b(SecretStr), + UserSalt = couch_util:get_value(<<"salt">>, Result), + FullSecret = <<Secret/binary, UserSalt/binary>>, + ExpectedHash = crypto:sha_mac(FullSecret, [User, ":", TimeStr]), + case ?l2b(string:join(HashParts, ":")) of + ExpectedHash -> + TimeStamp = erlang:list_to_integer(TimeStr, 16), + Timeout = erlang:list_to_integer(couch_config:get( + "chttpd_auth", "timeout", "600")), + CurrentTime = timestamp(), + if CurrentTime < TimeStamp + Timeout -> + TimeLeft = TimeStamp + Timeout - CurrentTime, + Req#httpd{user_ctx=#user_ctx{ + name=?l2b(User), + roles=couch_util:get_value(<<"roles">>, Result, []) + }, auth={FullSecret, TimeLeft < Timeout*0.9}}; + true -> + ?LOG_DEBUG("cookie for ~s was expired", [User]), + put(cookie_auth_failed, true), + Msg = lists:concat(["Your session has expired after ", + Timeout div 60, " minutes of inactivity"]), + throw({credentials_expired, ?l2b(Msg)}) + end; + _Else -> + ?LOG_DEBUG("cookie password hash was incorrect", []), + cookie_auth_failed + end + end + end + end. + +create_user(#httpd{method='POST', mochi_req=MochiReq}=Req, Db) -> + Form = parse_form(MochiReq), + {UserName, Password} = extract_username_password(Form), + case get_user(UserName) of + nil -> + Roles = [?l2b(R) || R <- proplists:get_all_values("roles", Form)], + if Roles /= [] -> + chttpd:verify_is_server_admin(Req); + true -> ok end, + Active = chttpd_view:parse_bool_param(couch_util:get_value("active", + Form, "true")), + UserSalt = couch_util:new_uuid(), + UserDoc = #doc{ + id = UserName, + body = {[ + {<<"active">>, Active}, + {<<"email">>, ?l2b(couch_util:get_value("email", Form, ""))}, + {<<"password_sha">>, hash_password(Password, UserSalt)}, + {<<"roles">>, Roles}, + {<<"salt">>, UserSalt}, + {<<"type">>, <<"user">>}, + {<<"username">>, UserName} + ]} + }, + {ok, _Rev} = couch_db:update_doc(Db, UserDoc, []), + ?LOG_DEBUG("User ~s (~s) with password, ~s created.", [UserName, + UserName, Password]), + send_response(Req); + _Result -> + ?LOG_DEBUG("Can't create ~s: already exists", [UserName]), + throw({forbidden, <<"User already exists.">>}) + end. + +delete_user(#httpd{user_ctx=UserCtx}=Req, Db, UserName) -> + case get_user(UserName) of + nil -> + throw({not_found, <<"User doesn't exist">>}); + User -> + case lists:member(<<"_admin">>,UserCtx#user_ctx.roles) of + true -> + ok; + false when UserCtx#user_ctx.name == UserName -> + ok; + false -> + throw({forbidden, <<"You aren't allowed to delete the user">>}) + end, + {Pos,Rev} = couch_doc:parse_rev(couch_util:get_value(<<"_rev">>,User)), + UserDoc = #doc{ + id = UserName, + revs = {Pos, [Rev]}, + deleted = true + }, + {ok, _Rev} = couch_db:update_doc(Db, UserDoc, []), + send_response(Req) + end. + +extract_username(Form) -> + try ?l2b(couch_util:get_value("username", Form)) + catch error:badarg -> + throw({bad_request, <<"user accounts must have a username">>}) + end. + +extract_password(Form) -> + try ?l2b(couch_util:get_value("password", Form)) + catch error:badarg -> + throw({bad_request, <<"user accounts must have a password">>}) + end. + +extract_username_password(Form) -> + try + {?l2b(couch_util:get_value("username", Form)), + ?l2b(couch_util:get_value("password", Form))} + catch error:badarg -> + Msg = <<"user accounts must have a username and password">>, + throw({bad_request, Msg}) + end. + +generate_cookie_buster() -> + T0 = calendar:now_to_datetime({0,86400,0}), + Opts = [{max_age,0}, {path,"/"}, {local_time,T0}], + mochiweb_cookies:cookie("AuthSession", "", Opts). + +generate_cookie(User, Secret, TimeStamp) -> + SessionData = ?b2l(User) ++ ":" ++ erlang:integer_to_list(TimeStamp, 16), + Hash = crypto:sha_mac(Secret, SessionData), + Cookie = couch_util:encodeBase64Url(SessionData ++ ":" ++ ?b2l(Hash)), + % MaxAge = erlang:list_to_integer(couch_config:get("chttpd_auth", + % "timeout", "600")), + % TODO add {secure, true} to options when SSL is detected + mochiweb_cookies:cookie("AuthSession", Cookie, [{path, "/"}]). + % {max_age, MaxAge}]). + +hash_password(Password, Salt) -> + ?l2b(couch_util:to_hex(crypto:sha(<<Password/binary, Salt/binary>>))). + +parse_form(MochiReq) -> + case MochiReq:get_primary_header_value("content-type") of + "application/x-www-form-urlencoded" ++ _ -> + ReqBody = MochiReq:recv_body(), + mochiweb_util:parse_qs(ReqBody); + _ -> + throw({bad_request, <<"you must specify " + "application/x-www-form-urlencoded as the primary content-type">>}) + end. + +send_response(Req) -> + send_response(Req, []). + +send_response(Req, ExtraHeaders) -> + {Code, Headers} = case chttpd:qs_value(Req, "next", nil) of + nil -> {200, []}; + Redirect -> + {302, [{"Location", chttpd:absolute_uri(Req, Redirect)}]} + end, + send_json(Req, Code, Headers ++ ExtraHeaders, {[{ok, true}]}). + +timestamp() -> + {MegaSeconds, Seconds, _} = erlang:now(), + MegaSeconds * 1000000 + Seconds. + +update_user(#httpd{mochi_req=MochiReq, user_ctx=UserCtx}=Req, Db, UserName) -> + case get_user(UserName) of + nil -> + throw({not_found, <<"User doesn't exist">>}); + User -> + Form = parse_form(MochiReq), + NewPassword = ?l2b(couch_util:get_value("password", Form, "")), + OldPassword = ?l2b(couch_util:get_value("old_password", Form, "")), + + UserSalt = couch_util:get_value(<<"salt">>, User), + CurrentPasswordHash = couch_util:get_value(<<"password_sha">>, User), + + Roles = [?l2b(R) || R <- proplists:get_all_values("roles", Form)], + if Roles /= [] -> + chttpd:verify_is_server_admin(Req); + true -> ok end, + + PasswordHash = case NewPassword of + <<>> -> + CurrentPasswordHash; + _Else -> + case lists:member(<<"_admin">>,UserCtx#user_ctx.roles) of + true -> + hash_password(NewPassword, UserSalt); + false when UserCtx#user_ctx.name == UserName -> + %% for user we test old password before allowing change + case hash_password(OldPassword, UserSalt) of + CurrentPasswordHash -> + hash_password(NewPassword, UserSalt); + _ -> + throw({forbidden, <<"Old password is incorrect.">>}) + end; + _ -> + Msg = <<"You aren't allowed to change this password.">>, + throw({forbidden, Msg}) + end + end, + + Active = chttpd_view:parse_bool_param(couch_util:get_value("active", + Form, "true")), + {Pos,Rev} = couch_doc:parse_rev(couch_util:get_value(<<"_rev">>,User)), + UserDoc = #doc{ + id = UserName, + revs = {Pos,[Rev]}, + body = {[ + {<<"active">>, Active}, + {<<"email">>, ?l2b(couch_util:get_value("email", Form, ""))}, + {<<"password_sha">>, PasswordHash}, + {<<"roles">>, Roles}, + {<<"salt">>, UserSalt}, + {<<"type">>, <<"user">>}, + {<<"username">>, UserName} + ]} + }, + {ok, _Rev} = couch_db:update_doc(Db, UserDoc, []), + ?LOG_DEBUG("User ~s updated.", [UserName]), + send_response(Req) + end. |