% 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_auth_cache). -behaviour(gen_server). % public API -export([get_user_creds/1]). % gen_server API -export([start_link/0, init/1, handle_call/3, handle_info/2, handle_cast/2]). -export([code_change/3, terminate/2]). -include("couch_db.hrl"). -include("couch_js_functions.hrl"). -define(STATE, auth_state_ets). -define(BY_USER, auth_by_user_ets). -define(BY_ATIME, auth_by_atime_ets). -record(state, { max_cache_size = 0, cache_size = 0, db_notifier = nil }). -spec get_user_creds(UserName::string() | binary()) -> Credentials::list() | nil. get_user_creds(UserName) when is_list(UserName) -> get_user_creds(?l2b(UserName)); get_user_creds(UserName) -> UserCreds = case couch_config:get("admins", ?b2l(UserName)) of "-hashed-" ++ HashedPwdAndSalt -> % the name is an admin, now check to see if there is a user doc % which has a matching name, salt, and password_sha [HashedPwd, Salt] = string:tokens(HashedPwdAndSalt, ","), case get_from_cache(UserName) of nil -> [{<<"roles">>, [<<"_admin">>]}, {<<"salt">>, ?l2b(Salt)}, {<<"password_sha">>, ?l2b(HashedPwd)}]; UserProps when is_list(UserProps) -> DocRoles = couch_util:get_value(<<"roles">>, UserProps), [{<<"roles">>, [<<"_admin">> | DocRoles]}, {<<"salt">>, ?l2b(Salt)}, {<<"password_sha">>, ?l2b(HashedPwd)}] end; _Else -> get_from_cache(UserName) end, validate_user_creds(UserCreds). get_from_cache(UserName) -> exec_if_auth_db( fun(_AuthDb) -> maybe_refresh_cache(), case ets:lookup(?BY_USER, UserName) of [] -> gen_server:call(?MODULE, {fetch, UserName}, infinity); [{UserName, {Credentials, _ATime}}] -> couch_stats_collector:increment({couchdb, auth_cache_hits}), gen_server:cast(?MODULE, {cache_hit, UserName}), Credentials end end, nil ). validate_user_creds(nil) -> nil; validate_user_creds(UserCreds) -> case couch_util:get_value(<<"_conflicts">>, UserCreds) of undefined -> ok; _ConflictList -> throw({unauthorized, <<"User document conflicts must be resolved before the document", " is used for authentication purposes.">> }) end, UserCreds. start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). init(_) -> ?STATE = ets:new(?STATE, [set, protected, named_table]), ?BY_USER = ets:new(?BY_USER, [set, protected, named_table]), ?BY_ATIME = ets:new(?BY_ATIME, [ordered_set, private, named_table]), AuthDbName = couch_config:get("couch_httpd_auth", "authentication_db"), true = ets:insert(?STATE, {auth_db_name, ?l2b(AuthDbName)}), true = ets:insert(?STATE, {auth_db, open_auth_db()}), process_flag(trap_exit, true), ok = couch_config:register( fun("couch_httpd_auth", "auth_cache_size", SizeList) -> Size = list_to_integer(SizeList), ok = gen_server:call(?MODULE, {new_max_cache_size, Size}, infinity) end ), ok = couch_config:register( fun("couch_httpd_auth", "authentication_db", DbName) -> ok = gen_server:call(?MODULE, {new_auth_db, ?l2b(DbName)}, infinity) end ), {ok, Notifier} = couch_db_update_notifier:start_link(fun handle_db_event/1), State = #state{ db_notifier = Notifier, max_cache_size = list_to_integer( couch_config:get("couch_httpd_auth", "auth_cache_size", "50") ) }, {ok, State}. handle_db_event({Event, DbName}) -> [{auth_db_name, AuthDbName}] = ets:lookup(?STATE, auth_db_name), case DbName =:= AuthDbName of true -> case Event of deleted -> gen_server:call(?MODULE, auth_db_deleted, infinity); created -> gen_server:call(?MODULE, auth_db_created, infinity); compacted -> gen_server:call(?MODULE, auth_db_compacted, infinity); _Else -> ok end; false -> ok end. handle_call({new_auth_db, AuthDbName}, _From, State) -> NewState = clear_cache(State), true = ets:insert(?STATE, {auth_db_name, AuthDbName}), true = ets:insert(?STATE, {auth_db, open_auth_db()}), {reply, ok, NewState}; handle_call(auth_db_deleted, _From, State) -> NewState = clear_cache(State), true = ets:insert(?STATE, {auth_db, nil}), {reply, ok, NewState}; handle_call(auth_db_created, _From, State) -> NewState = clear_cache(State), true = ets:insert(?STATE, {auth_db, open_auth_db()}), {reply, ok, NewState}; handle_call(auth_db_compacted, _From, State) -> exec_if_auth_db( fun(AuthDb) -> true = ets:insert(?STATE, {auth_db, reopen_auth_db(AuthDb)}) end ), {reply, ok, State}; handle_call({new_max_cache_size, NewSize}, _From, State) -> case NewSize >= State#state.cache_size of true -> ok; false -> lists:foreach( fun(_) -> LruTime = ets:last(?BY_ATIME), [{LruTime, UserName}] = ets:lookup(?BY_ATIME, LruTime), true = ets:delete(?BY_ATIME, LruTime), true = ets:delete(?BY_USER, UserName) end, lists:seq(1, State#state.cache_size - NewSize) ) end, NewState = State#state{ max_cache_size = NewSize, cache_size = lists:min([NewSize, State#state.cache_size]) }, {reply, ok, NewState}; handle_call({fetch, UserName}, _From, State) -> {Credentials, NewState} = case ets:lookup(?BY_USER, UserName) of [{UserName, {Creds, ATime}}] -> couch_stats_collector:increment({couchdb, auth_cache_hits}), cache_hit(UserName, Creds, ATime), {Creds, State}; [] -> couch_stats_collector:increment({couchdb, auth_cache_misses}), Creds = get_user_props_from_db(UserName), State1 = add_cache_entry(UserName, Creds, erlang:now(), State), {Creds, State1} end, {reply, Credentials, NewState}; handle_call(refresh, _From, State) -> exec_if_auth_db(fun refresh_entries/1), {reply, ok, State}. handle_cast({cache_hit, UserName}, State) -> case ets:lookup(?BY_USER, UserName) of [{UserName, {Credentials, ATime}}] -> cache_hit(UserName, Credentials, ATime); _ -> ok end, {noreply, State}. handle_info(_Msg, State) -> {noreply, State}. terminate(_Reason, #state{db_notifier = Notifier}) -> couch_db_update_notifier:stop(Notifier), exec_if_auth_db(fun(AuthDb) -> catch couch_db:close(AuthDb) end), true = ets:delete(?BY_USER), true = ets:delete(?BY_ATIME), true = ets:delete(?STATE). code_change(_OldVsn, State, _Extra) -> {ok, State}. clear_cache(State) -> exec_if_auth_db(fun(AuthDb) -> catch couch_db:close(AuthDb) end), true = ets:delete_all_objects(?BY_USER), true = ets:delete_all_objects(?BY_ATIME), State#state{cache_size = 0}. add_cache_entry(UserName, Credentials, ATime, State) -> case State#state.cache_size >= State#state.max_cache_size of true -> free_mru_cache_entry(); false -> ok end, true = ets:insert(?BY_ATIME, {ATime, UserName}), true = ets:insert(?BY_USER, {UserName, {Credentials, ATime}}), State#state{cache_size = couch_util:get_value(size, ets:info(?BY_USER))}. free_mru_cache_entry() -> case ets:last(?BY_ATIME) of '$end_of_table' -> ok; % empty cache LruTime -> [{LruTime, UserName}] = ets:lookup(?BY_ATIME, LruTime), true = ets:delete(?BY_ATIME, LruTime), true = ets:delete(?BY_USER, UserName) end. cache_hit(UserName, Credentials, ATime) -> NewATime = erlang:now(), true = ets:delete(?BY_ATIME, ATime), true = ets:insert(?BY_ATIME, {NewATime, UserName}), true = ets:insert(?BY_USER, {UserName, {Credentials, NewATime}}). refresh_entries(AuthDb) -> case reopen_auth_db(AuthDb) of nil -> ok; AuthDb2 -> case AuthDb2#db.update_seq > AuthDb#db.update_seq of true -> {ok, _, _} = couch_db:enum_docs_since( AuthDb2, AuthDb#db.update_seq, fun(DocInfo, _, _) -> refresh_entry(AuthDb2, DocInfo) end, AuthDb#db.update_seq, [] ), true = ets:insert(?STATE, {auth_db, AuthDb2}); false -> ok end end. refresh_entry(Db, #doc_info{high_seq = DocSeq} = DocInfo) -> case is_user_doc(DocInfo) of {true, UserName} -> case ets:lookup(?BY_USER, UserName) of [] -> ok; [{UserName, {_OldCreds, ATime}}] -> {ok, Doc} = couch_db:open_doc(Db, DocInfo, [conflicts, deleted]), NewCreds = user_creds(Doc), true = ets:insert(?BY_USER, {UserName, {NewCreds, ATime}}) end; false -> ok end, {ok, DocSeq}. user_creds(#doc{deleted = true}) -> nil; user_creds(#doc{} = Doc) -> {Creds} = couch_query_servers:json_doc(Doc), Creds. is_user_doc(#doc_info{id = <<"org.couchdb.user:", UserName/binary>>}) -> {true, UserName}; is_user_doc(_) -> false. maybe_refresh_cache() -> case cache_needs_refresh() of true -> ok = gen_server:call(?MODULE, refresh, infinity); false -> ok end. cache_needs_refresh() -> exec_if_auth_db( fun(AuthDb) -> case reopen_auth_db(AuthDb) of nil -> false; AuthDb2 -> AuthDb2#db.update_seq > AuthDb#db.update_seq end end, false ). reopen_auth_db(AuthDb) -> case (catch couch_db:reopen(AuthDb)) of {ok, AuthDb2} -> AuthDb2; _ -> nil end. exec_if_auth_db(Fun) -> exec_if_auth_db(Fun, ok). exec_if_auth_db(Fun, DefRes) -> case ets:lookup(?STATE, auth_db) of [{auth_db, #db{} = AuthDb}] -> Fun(AuthDb); _ -> DefRes end. open_auth_db() -> [{auth_db_name, DbName}] = ets:lookup(?STATE, auth_db_name), {ok, AuthDb} = ensure_users_db_exists(DbName, [sys_db]), AuthDb. get_user_props_from_db(UserName) -> exec_if_auth_db( fun(AuthDb) -> Db = reopen_auth_db(AuthDb), DocId = <<"org.couchdb.user:", UserName/binary>>, try {ok, Doc} = couch_db:open_doc(Db, DocId, [conflicts]), {DocProps} = couch_query_servers:json_doc(Doc), DocProps catch _:_Error -> nil end end, nil ). ensure_users_db_exists(DbName, Options) -> Options1 = [{user_ctx, #user_ctx{roles=[<<"_admin">>]}} | Options], case couch_db:open(DbName, Options1) of {ok, Db} -> ensure_auth_ddoc_exists(Db, <<"_design/_auth">>), {ok, Db}; _Error -> {ok, Db} = couch_db:create(DbName, Options1), ok = ensure_auth_ddoc_exists(Db, <<"_design/_auth">>), {ok, Db} end. ensure_auth_ddoc_exists(Db, DDocId) -> case couch_db:open_doc(Db, DDocId) of {not_found, _Reason} -> {ok, AuthDesign} = auth_design_doc(DDocId), {ok, _Rev} = couch_db:update_doc(Db, AuthDesign, []); _ -> ok end, ok. auth_design_doc(DocId) -> DocProps = [ {<<"_id">>, DocId}, {<<"language">>,<<"javascript">>}, {<<"validate_doc_update">>, ?AUTH_DB_DOC_VALIDATE_FUNCTION} ], {ok, couch_doc:from_json_obj({DocProps})}.