blob: e0715b88479e499c96c31ca4f4249cf121e9ebf5 [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(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})}.