blob: c5a56bddbc4f5d887dc6e6f334675e648f7871c6 [file] [log] [blame]
% 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_cache).
-behaviour(gen_server).
-behaviour(config_listener).
-export([start_link/0, get_user_creds/2, update_user_creds/3]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2,
code_change/3]).
-export([listen_for_changes/1, changes_callback/2]).
-export([handle_config_change/5, handle_config_terminate/3]).
-include_lib("couch/include/couch_db.hrl").
-include_lib("couch/include/couch_js_functions.hrl").
-define(CACHE, chttpd_auth_cache_lru).
-define(RELISTEN_DELAY, 5000).
-record(state, {
changes_pid,
last_seq="0"
}).
%% public functions
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
get_user_creds(Req, UserName) when is_list(UserName) ->
get_user_creds(Req, ?l2b(UserName));
get_user_creds(_Req, UserName) when is_binary(UserName) ->
Resp = case couch_auth_cache:get_admin(UserName) of
nil ->
get_from_cache(UserName);
Props ->
case get_from_cache(UserName) of
nil ->
Props;
UserProps when is_list(UserProps) ->
couch_auth_cache:add_roles(Props,
couch_util:get_value(<<"roles">>, UserProps))
end
end,
maybe_validate_user_creds(Resp).
update_user_creds(_Req, UserDoc, _Ctx) ->
{_, Ref} = spawn_monitor(fun() ->
{ok, Db} = fabric2_db:open(dbname(), [?ADMIN_CTX]),
case fabric2_db:update_doc(Db, UserDoc) of
{ok, _} ->
exit(ok);
Else ->
exit(Else)
end
end),
receive
{'DOWN', Ref, _, _, ok} ->
ok;
{'DOWN', Ref, _, _, Else} ->
Else
end.
get_from_cache(UserName) ->
try ets_lru:lookup_d(?CACHE, UserName) of
{ok, Props} ->
couch_stats:increment_counter([couchdb, auth_cache_hits]),
couch_log:debug("cache hit for ~s", [UserName]),
Props;
_ ->
maybe_increment_auth_cache_miss(UserName),
case load_user_from_db(UserName) of
nil ->
nil;
Props ->
ets_lru:insert(?CACHE, UserName, Props),
Props
end
catch
error:badarg ->
maybe_increment_auth_cache_miss(UserName),
load_user_from_db(UserName)
end.
maybe_increment_auth_cache_miss(UserName) ->
Admins = config:get("admins"),
case lists:keymember(?b2l(UserName), 1, Admins) of
false ->
couch_stats:increment_counter([couchdb, auth_cache_misses]),
couch_log:debug("cache miss for ~s", [UserName]);
_True ->
ok
end.
%% gen_server callbacks
init([]) ->
ensure_auth_db(),
ok = config:listen_for_changes(?MODULE, nil),
self() ! {start_listener, 0},
{ok, #state{}}.
handle_call(reinit_cache, _From, State) ->
#state{
changes_pid = Pid
} = State,
% The database may currently be cached. This
% ensures that we've removed it so that the
% system db callbacks are installed.
fabric2_server:remove(dbname()),
ensure_auth_db(),
ets_lru:clear(?CACHE),
exit(Pid, shutdown),
self() ! {start_listener, 0},
{reply, ok, State#state{changes_pid = undefined}};
handle_call(_Call, _From, State) ->
{noreply, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info({'DOWN', _, _, Pid, Reason}, #state{changes_pid=Pid} = State) ->
Seq = case Reason of
{seq, EndSeq} ->
EndSeq;
{database_does_not_exist, _} ->
couch_log:notice("~p changes listener died because the _users database does not exist. Create the database to silence this notice.", [?MODULE]),
0;
_ ->
couch_log:notice("~p changes listener died ~r", [?MODULE, Reason]),
0
end,
erlang:send_after(5000, self(), {start_listener, Seq}),
{noreply, State#state{last_seq=Seq}};
handle_info({start_listener, Seq}, State) ->
{noreply, State#state{changes_pid = spawn_changes(Seq)}};
handle_info(restart_config_listener, State) ->
ok = config:listen_for_changes(?MODULE, nil),
{noreply, State};
handle_info(_Msg, State) ->
{noreply, State}.
terminate(_Reason, #state{changes_pid = Pid}) when is_pid(Pid) ->
exit(Pid, kill);
terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, #state{}=State, _Extra) ->
{ok, State}.
%% private functions
spawn_changes(Since) ->
{Pid, _} = spawn_monitor(?MODULE, listen_for_changes, [Since]),
Pid.
listen_for_changes(Since) ->
{ok, Db} = fabric2_db:open(dbname(), [?ADMIN_CTX]),
ensure_auth_ddoc_exists(Db, <<"_design/_auth">>),
CBFun = fun ?MODULE:changes_callback/2,
Args = #changes_args{
feed = "continuous",
since = Since,
heartbeat = true,
filter = {default, main_only}
},
ChangesFun = chttpd_changes:handle_db_changes(Args, nil, Db),
ChangesFun({CBFun, Since}).
changes_callback(waiting_for_updates, Acc) ->
{ok, Acc};
changes_callback(start, Since) ->
{ok, Since};
changes_callback({stop, EndSeq, _Pending}, _) ->
exit({seq, EndSeq});
changes_callback({change, {Change}}, _) ->
case couch_util:get_value(<<"id">>, Change) of
<<"_design/", _/binary>> ->
ok;
DocId ->
UserName = username(DocId),
couch_log:debug("Invalidating cached credentials for ~s", [UserName]),
ets_lru:remove(?CACHE, UserName)
end,
{ok, couch_util:get_value(seq, Change)};
changes_callback({timeout, _ResponseType}, Acc) ->
{ok, Acc};
changes_callback({error, _}, EndSeq) ->
exit({seq, EndSeq}).
handle_config_change("chttpd_auth", "authentication_db", _DbName, _, _) ->
{ok, gen_server:call(?MODULE, reinit_cache, infinity)};
handle_config_change(_, _, _, _, _) ->
{ok, nil}.
handle_config_terminate(_, stop, _) ->
ok;
handle_config_terminate(_Server, _Reason, _State) ->
Dst = whereis(?MODULE),
erlang:send_after(?RELISTEN_DELAY, Dst, restart_config_listener).
load_user_from_db(UserName) ->
{ok, Db} = fabric2_db:open(dbname(), [?ADMIN_CTX]),
try fabric2_db:open_doc(Db, docid(UserName), [conflicts]) of
{ok, Doc} ->
{Props} = couch_doc:to_json_obj(Doc, []),
Props;
_Else ->
couch_log:debug("no record of user ~s", [UserName]),
nil
catch error:database_does_not_exist ->
nil
end.
ensure_auth_db() ->
try
fabric2_db:open(dbname(), [?ADMIN_CTX])
catch error:database_does_not_exist ->
case fabric2_db:create(dbname(), [?ADMIN_CTX]) of
{ok, _} -> ok;
{error, file_exists} -> ok
end
end.
dbname() ->
DbNameStr = config:get("chttpd_auth", "authentication_db", "_users"),
iolist_to_binary(DbNameStr).
docid(UserName) ->
<<"org.couchdb.user:", UserName/binary>>.
username(<<"org.couchdb.user:", UserName/binary>>) ->
UserName.
ensure_auth_ddoc_exists(Db, DDocId) ->
case fabric2_db:open_doc(Db, DDocId) of
{not_found, _Reason} ->
{ok, AuthDesign} = couch_auth_cache:auth_design_doc(DDocId),
update_doc_ignoring_conflict(Db, AuthDesign);
{ok, Doc} ->
{Props} = couch_doc:to_json_obj(Doc, []),
case couch_util:get_value(<<"validate_doc_update">>, Props, []) of
?AUTH_DB_DOC_VALIDATE_FUNCTION ->
ok;
_ ->
Props1 = lists:keyreplace(<<"validate_doc_update">>, 1, Props,
{<<"validate_doc_update">>,
?AUTH_DB_DOC_VALIDATE_FUNCTION}),
NewDoc = couch_doc:from_json_obj({Props1}),
update_doc_ignoring_conflict(Db, NewDoc)
end;
{error, Reason} ->
couch_log:notice("Failed to ensure auth ddoc ~s/~s exists for reason: ~p", [dbname(), DDocId, Reason]),
ok
end,
ok.
update_doc_ignoring_conflict(DbName, Doc) ->
try
fabric2_db:update_doc(DbName, Doc)
catch
error:conflict ->
ok
end.
maybe_validate_user_creds(nil) ->
nil;
% throws if UserCreds includes a _conflicts member
% returns UserCreds otherwise
maybe_validate_user_creds(UserCreds) ->
AllowConflictedUserDocs = config:get_boolean("chttpd_auth", "allow_conflicted_user_docs", false),
case {couch_util:get_value(<<"_conflicts">>, UserCreds), AllowConflictedUserDocs} of
{undefined, _} ->
{ok, UserCreds, nil};
{_, true} ->
{ok, UserCreds, nil};
{_ConflictList, false} ->
throw({unauthorized,
<<"User document conflicts must be resolved before the document",
" is used for authentication purposes.">>
})
end.