./ct_report/coverage/mod_auth_token.COVER.html

1 -module(mod_auth_token).
2
3 -behaviour(gen_mod).
4 -behaviour(mongoose_module_metrics).
5
6 -include("mongoose.hrl").
7 -include("jlib.hrl").
8 -include("mod_auth_token.hrl").
9 -include("mongoose_config_spec.hrl").
10
11 %% gen_mod callbacks
12 -export([start/2]).
13 -export([stop/1]).
14 -export([hooks/1]).
15 -export([supported_features/0]).
16 -export([config_spec/0]).
17
18 %% Hook handlers
19 -export([clean_tokens/3,
20 disco_local_features/3]).
21
22 %% gen_iq_handler handlers
23 -export([process_iq/5]).
24
25 %% Public API
26 -export([authenticate/2,
27 revoke/2,
28 token/3]).
29
30 %% Token serialization
31 -export([deserialize/1,
32 serialize/1]).
33
34 %% Command-line interface
35 -export([revoke_token_command/1]).
36
37 %% Test only!
38 -export([datetime_to_seconds/1,
39 seconds_to_datetime/1]).
40 -export([expiry_datetime/3,
41 get_key_for_host_type/2,
42 token_with_mac/2]).
43
44 -export([config_metrics/1]).
45
46 -export_type([period/0,
47 sequence_no/0,
48 token/0,
49 token_type/0]).
50
51 -ignore_xref([
52 behaviour_info/1, datetime_to_seconds/1, deserialize/1,
53 expiry_datetime/3, get_key_for_host_type/2, process_iq/5,
54 revoke/2, revoke_token_command/1, seconds_to_datetime/1,
55 serialize/1, token/3, token_with_mac/2
56 ]).
57
58 -type error() :: error | {error, any()}.
59 -type period() :: #{value := non_neg_integer(),
60 unit := days | hours | minutes | seconds}.
61 -type sequence_no() :: integer().
62 -type serialized() :: binary().
63 -type token() :: #token{}.
64 -type token_type() :: access | refresh | provision.
65 -type validation_result() :: {ok, module(), jid:user()}
66 | {ok, module(), jid:user(), binary()}
67 | error().
68
69 -define(A2B(A), atom_to_binary(A, utf8)).
70
71 -define(I2B(I), integer_to_binary(I)).
72 -define(B2I(B), binary_to_integer(B)).
73
74 %%
75 %% gen_mod callbacks
76 %%
77
78 -spec start(mongooseim:host_type(), gen_mod:module_opts()) -> ok.
79 start(HostType, #{iqdisc := IQDisc} = Opts) ->
80 6 mod_auth_token_backend:start(HostType, Opts),
81 6 gen_iq_handler:add_iq_handler_for_domain(
82 HostType, ?NS_ESL_TOKEN_AUTH, ejabberd_sm,
83 fun ?MODULE:process_iq/5, #{}, IQDisc),
84 6 ok.
85
86 -spec stop(mongooseim:host_type()) -> ok.
87 stop(HostType) ->
88 6 gen_iq_handler:remove_iq_handler_for_domain(HostType, ?NS_ESL_TOKEN_AUTH, ejabberd_sm),
89 6 ok.
90
91 hooks(HostType) ->
92 12 [{disco_local_features, HostType, fun ?MODULE:disco_local_features/3, #{}, 90},
93 {remove_user, HostType, fun ?MODULE:clean_tokens/3, #{}, 50}].
94
95 -spec supported_features() -> [atom()].
96 supported_features() ->
97 8 [dynamic_domains].
98
99 -spec config_spec() -> mongoose_config_spec:config_section().
100 config_spec() ->
101 144 #section{
102 items = #{<<"backend">> => #option{type = atom,
103 validate = {module, mod_auth_token}},
104 <<"validity_period">> => validity_periods_spec(),
105 <<"iqdisc">> => mongoose_config_spec:iqdisc()},
106 defaults = #{<<"backend">> => rdbms,
107 <<"iqdisc">> => no_queue}
108 }.
109
110 validity_periods_spec() ->
111 144 #section{
112 items = #{<<"access">> => validity_period_spec(),
113 <<"refresh">> => validity_period_spec()},
114 defaults = #{<<"access">> => #{value => 1, unit => hours},
115 <<"refresh">> => #{value => 25, unit => days}},
116 include = always
117 }.
118
119 validity_period_spec() ->
120 288 #section{
121 items = #{<<"value">> => #option{type = integer,
122 validate = non_negative},
123 <<"unit">> => #option{type = atom,
124 validate = {enum, [days, hours, minutes, seconds]}}
125 },
126 required = all
127 }.
128
129 %%
130 %% Other stuff
131 %%
132
133 -spec serialize(token()) -> serialized().
134
:-(
serialize(#token{mac_signature = undefined} = T) -> error(incomplete_token, [T]);
135
:-(
serialize(#token{token_body = undefined} = T) -> error(incomplete_token, [T]);
136 serialize(#token{token_body = Body, mac_signature = MAC}) ->
137 44 <<Body/bytes, (field_separator()), (base16:encode(MAC))/bytes>>.
138
139 %% #token{} contains fields which are:
140 %% - primary - these have to be supplied on token creation,
141 %% - dependent - these are computed based on the primary fields.
142 %% `token_with_mac/2` computes dependent fields and stores them in the record
143 %% based on a record with just the primary fields.
144 -spec token_with_mac(mongooseim:host_type(), token()) -> token().
145 token_with_mac(HostType, #token{mac_signature = undefined, token_body = undefined} = T) ->
146 45 Body = join_fields(T),
147 45 MAC = keyed_hash(Body, hmac_opts(HostType, T#token.type)),
148 45 T#token{token_body = Body, mac_signature = MAC}.
149
150 -spec hmac_opts(mongooseim:host_type(), token_type()) -> [{any(), any()}].
151 hmac_opts(HostType, TokenType) ->
152 51 lists:keystore(key, 1, hmac_opts(),
153 {key, get_key_for_host_type(HostType, TokenType)}).
154
155 102 field_separator() -> 0.
156
157 join_fields(T) ->
158 51 Sep = field_separator(),
159 51 #token{type = Type, expiry_datetime = Expiry, user_jid = JID,
160 sequence_no = SeqNo, vcard = VCard} = T,
161 51 case {Type, SeqNo} of
162 {access, undefined} ->
163 23 <<(?A2B(Type))/bytes, Sep,
164 (jid:to_binary(JID))/bytes, Sep,
165 (?I2B(datetime_to_seconds(Expiry)))/bytes>>;
166 {refresh, _} ->
167 26 <<(?A2B(Type))/bytes, Sep,
168 (jid:to_binary(JID))/bytes, Sep,
169 (?I2B(datetime_to_seconds(Expiry)))/bytes, Sep,
170 (?I2B(SeqNo))/bytes>>;
171 {provision, undefined} ->
172 2 <<(?A2B(Type))/bytes, Sep,
173 (jid:to_binary(JID))/bytes, Sep,
174 (?I2B(datetime_to_seconds(Expiry)))/bytes, Sep,
175 (exml:to_binary(VCard))/bytes>>
176 end.
177
178 keyed_hash(Data, Opts) ->
179 51 Type = proplists:get_value(hmac_type, Opts, sha384),
180 51 {key, Key} = lists:keyfind(key, 1, Opts),
181 51 crypto:mac(hmac, Type, Key, Data).
182
183 hmac_opts() ->
184 51 [].
185
186 -spec deserialize(serialized()) -> token().
187 deserialize(Serialized) when is_binary(Serialized) ->
188 7 get_token_as_record(Serialized).
189
190 -spec revoke(mongooseim:host_type(), jid:jid()) -> ok | not_found | error.
191 revoke(HostType, Owner) ->
192 12 try
193 12 mod_auth_token_backend:revoke(HostType, Owner)
194 catch
195 Class:Reason:Stacktrace ->
196
:-(
?LOG_ERROR(#{what => auth_token_revoke_failed,
197 user => Owner#jid.luser, server => Owner#jid.lserver,
198
:-(
class => Class, reason => Reason, stacktrace => Stacktrace}),
199
:-(
error
200 end.
201
202 -spec authenticate(mongooseim:host_type(), serialized()) -> validation_result().
203 authenticate(HostType, SerializedToken) ->
204 7 try
205 7 do_authenticate(HostType, SerializedToken)
206 catch
207 1 _:_ -> {error, internal_server_error}
208 end.
209
210 do_authenticate(HostType, SerializedToken) ->
211 7 #token{user_jid = Owner} = Token = deserialize(SerializedToken),
212 6 {Criteria, Result} = validate_token(HostType, Token),
213 6 ?LOG_INFO(#{what => auth_token_validate,
214 user => Owner#jid.luser, server => Owner#jid.lserver,
215 6 criteria => Criteria, result => Result}),
216 6 case {Result, Token#token.type} of
217 {ok, access} ->
218 1 {ok, mod_auth_token, Owner#jid.luser};
219 {ok, refresh} ->
220 2 case token(HostType, Owner, access) of
221 #token{} = T ->
222 2 {ok, mod_auth_token, Owner#jid.luser, serialize(T)};
223 {error, R} ->
224
:-(
{error, R}
225 end;
226 {ok, provision} ->
227 1 case set_vcard(HostType, Owner, Token#token.vcard) of
228 {error, Reason} ->
229
:-(
?LOG_WARNING(#{what => auth_token_set_vcard_failed,
230 reason => Reason, token_vcard => Token#token.vcard,
231 user => Owner#jid.luser, server => Owner#jid.lserver,
232
:-(
criteria => Criteria, result => Result}),
233
:-(
{ok, mod_auth_token, Owner#jid.luser};
234 ok ->
235 1 {ok, mod_auth_token, Owner#jid.luser}
236 end;
237 {error, _} ->
238 2 {error, {Owner#jid.luser, [ Criterion
239 2 || {_, false} = Criterion <- Criteria ]}}
240 end.
241
242 set_vcard(HostType, #jid{} = User, #xmlel{} = VCard) ->
243 1 mongoose_hooks:set_vcard(HostType, User, VCard).
244
245 validate_token(HostType, Token) ->
246 6 Criteria = [{mac_valid, is_mac_valid(HostType, Token)},
247 {not_expired, is_not_expired(Token)},
248 {not_revoked, not is_revoked(Token, HostType)}],
249 6 Result = case Criteria of
250 4 [{_, true}, {_, true}, {_, true}] -> ok;
251 2 _ -> error
252 end,
253 6 {Criteria, Result}.
254
255 is_mac_valid(HostType, #token{type = Type, token_body = Body, mac_signature = ReceivedMAC}) ->
256 6 ComputedMAC = keyed_hash(Body, hmac_opts(HostType, Type)),
257 6 ReceivedMAC =:= ComputedMAC.
258
259 is_not_expired(#token{expiry_datetime = Expiry}) ->
260 6 utc_now_as_seconds() < datetime_to_seconds(Expiry).
261
262 is_revoked(#token{type = T}, _) when T =:= access;
263 T =:= provision ->
264 2 false;
265 is_revoked(#token{type = refresh, sequence_no = TokenSeqNo} = T, HostType) ->
266 4 try
267 4 ValidSeqNo = mod_auth_token_backend:get_valid_sequence_number(HostType, T#token.user_jid),
268 4 TokenSeqNo < ValidSeqNo
269 catch
270 Class:Reason:Stacktrace ->
271
:-(
?LOG_ERROR(#{what => auth_token_revocation_check_failed,
272 text => <<"Error checking revocation status">>,
273 token_seq_no => TokenSeqNo,
274
:-(
class => Class, reason => Reason, stacktrace => Stacktrace}),
275
:-(
true
276 end.
277
278 -spec process_iq(mongoose_acc:t(), jid:jid(), jid:jid(), jlib:iq(), any()) ->
279 {mongoose_acc:t(), jlib:iq()} | error().
280 process_iq(Acc, From, To, #iq{xmlns = ?NS_ESL_TOKEN_AUTH} = IQ, _Extra) ->
281 6 IQResp = process_local_iq(Acc, From, To, IQ),
282 6 {Acc, IQResp};
283 process_iq(Acc, _From, _To, #iq{} = IQ, _Extra) ->
284
:-(
{Acc, iq_error(IQ, [mongoose_xmpp_errors:bad_request()])}.
285
286 process_local_iq(Acc, From, _To, IQ) ->
287 6 try create_token_response(Acc, From, IQ) of
288 6 #iq{} = Response -> Response;
289
:-(
{error, Reason} -> iq_error(IQ, [Reason])
290 catch
291
:-(
_:_ -> iq_error(IQ, [mongoose_xmpp_errors:internal_server_error()])
292 end.
293
294 iq_error(IQ, SubElements) when is_list(SubElements) ->
295
:-(
IQ#iq{type = error, sub_el = SubElements}.
296
297 create_token_response(Acc, From, IQ) ->
298 6 HostType = mongoose_acc:host_type(Acc),
299 6 case {token(HostType, From, access), token(HostType, From, refresh)} of
300 {#token{} = AccessToken, #token{} = RefreshToken} ->
301 6 IQ#iq{type = result,
302 sub_el = [#xmlel{name = <<"items">>,
303 attrs = [{<<"xmlns">>, ?NS_ESL_TOKEN_AUTH}],
304 children = [token_to_xmlel(AccessToken),
305 token_to_xmlel(RefreshToken)]}]};
306
:-(
{_, _} -> {error, mongoose_xmpp_errors:internal_server_error()}
307 end.
308
309 -spec datetime_to_seconds(calendar:datetime()) -> non_neg_integer().
310 datetime_to_seconds(DateTime) ->
311 106 calendar:datetime_to_gregorian_seconds(DateTime).
312
313 -spec seconds_to_datetime(non_neg_integer()) -> calendar:datetime().
314 seconds_to_datetime(Seconds) ->
315 49 calendar:gregorian_seconds_to_datetime(Seconds).
316
317 utc_now_as_seconds() ->
318 49 datetime_to_seconds(calendar:universal_time()).
319
320 -spec token(mongooseim:host_type(), jid:jid(), token_type()) -> token() | error().
321 token(HostType, User, Type) ->
322 43 ExpiryTime = expiry_datetime(HostType, Type, utc_now_as_seconds()),
323 43 T = #token{type = Type, expiry_datetime = ExpiryTime, user_jid = User},
324 43 try
325 43 T2 = case Type of
326 22 access -> T;
327 refresh ->
328 21 ValidSeqNo = mod_auth_token_backend:get_valid_sequence_number(HostType, User),
329 21 T#token{sequence_no = ValidSeqNo}
330 end,
331 43 token_with_mac(HostType, T2)
332 catch
333 Class:Reason:Stacktrace ->
334
:-(
?LOG_ERROR(#{what => auth_token_revocation_check_failed,
335 text => <<"Error creating token sequence number">>,
336 token_type => Type, expiry_datetime => ExpiryTime,
337 user => User#jid.luser, server => User#jid.lserver,
338
:-(
class => Class, reason => Reason, stacktrace => Stacktrace}),
339
:-(
{error, {Class, Reason}}
340 end.
341
342 -spec expiry_datetime(mongooseim:host_type(), token_type(), non_neg_integer()) ->
343 calendar:datetime().
344 expiry_datetime(HostType, Type, UTCSeconds) ->
345 43 #{value := Value, unit := Unit} = get_validity_period(HostType, Type),
346 43 seconds_to_datetime(UTCSeconds + period_to_seconds(Value, Unit)).
347
348 -spec get_validity_period(mongooseim:host_type(), token_type()) -> period().
349 get_validity_period(HostType, Type) ->
350 43 gen_mod:get_module_opt(HostType, ?MODULE, [validity_period, Type]).
351
352 21 period_to_seconds(Days, days) -> 24 * 3600 * Days;
353
:-(
period_to_seconds(Hours, hours) -> 3600 * Hours;
354 22 period_to_seconds(Minutes, minutes) -> 60 * Minutes;
355
:-(
period_to_seconds(Seconds, seconds) -> Seconds.
356
357 token_to_xmlel(#token{type = Type} = T) ->
358 12 #xmlel{name = case Type of
359 6 access -> <<"access_token">>;
360 6 refresh -> <<"refresh_token">>
361 end,
362 attrs = [{<<"xmlns">>, ?NS_ESL_TOKEN_AUTH}],
363 children = [#xmlcdata{content = jlib:encode_base64(serialize(T))}]}.
364
365 %% args: Token with Mac decoded from transport, #token
366 %% is shared between tokens. Introduce other container types if
367 %% they start to differ more than a few fields.
368 -spec get_token_as_record(BToken) -> Token when
369 BToken :: serialized(),
370 Token :: token().
371 get_token_as_record(BToken) ->
372 7 [BType, User, Expiry | Rest] = binary:split(BToken, <<(field_separator())>>, [global]),
373 6 T = #token{type = decode_token_type(BType),
374 expiry_datetime = seconds_to_datetime(binary_to_integer(Expiry)),
375 user_jid = jid:from_binary(User)},
376 6 T1 = case {BType, Rest} of
377 {<<"access">>, [BMAC]} ->
378 1 T#token{mac_signature = base16:decode(BMAC)};
379 {<<"refresh">>, [BSeqNo, BMAC]} ->
380 4 T#token{sequence_no = ?B2I(BSeqNo),
381 mac_signature = base16:decode(BMAC)};
382 {<<"provision">>, [BVCard, BMAC]} ->
383 1 {ok, VCard} = exml:parse(BVCard),
384 1 T#token{vcard = VCard,
385 mac_signature = base16:decode(BMAC)}
386 end,
387 6 T1#token{token_body = join_fields(T1)}.
388
389 -spec decode_token_type(binary()) -> token_type().
390 decode_token_type(<<"access">>) ->
391 1 access;
392 decode_token_type(<<"refresh">>) ->
393 4 refresh;
394 decode_token_type(<<"provision">>) ->
395 1 provision.
396
397 -spec get_key_for_host_type(mongooseim:host_type(), token_type()) -> binary().
398 get_key_for_host_type(HostType, TokenType) ->
399 51 KeyName = key_name(TokenType),
400 51 [{{KeyName, _UsersHost}, RawKey}] = mongoose_hooks:get_key(HostType, KeyName),
401 51 RawKey.
402
403 -spec key_name(token_type()) -> token_secret | provision_pre_shared.
404 23 key_name(access) -> token_secret;
405 26 key_name(refresh) -> token_secret;
406 2 key_name(provision) -> provision_pre_shared.
407
408 -spec revoke_token_command(Owner) -> ResTuple when
409 Owner :: binary(),
410 ResCode :: ok | not_found | error,
411 ResTuple :: {ResCode, string()}.
412 revoke_token_command(Owner) ->
413
:-(
JID = jid:from_binary(Owner),
414
:-(
case mod_auth_token_api:revoke_token_command(JID) of
415
:-(
{ok, _} = Result -> Result;
416
:-(
Error -> Error
417 end.
418
419 -spec clean_tokens(Acc, Params, Extra) -> {ok, Acc} when
420 Acc :: mongoose_acc:t(),
421 Params :: #{jid := jid:jid()},
422 Extra :: gen_hook:extra().
423 clean_tokens(Acc, #{jid := Owner}, _) ->
424 30 HostType = mongoose_acc:host_type(Acc),
425 30 try
426 30 mod_auth_token_backend:clean_tokens(HostType, Owner)
427 catch
428 Class:Reason:Stacktrace ->
429
:-(
?LOG_ERROR(#{what => auth_token_clean_tokens_failed,
430 text => <<"Error in clean_tokens backend">>,
431 jid => jid:to_binary(Owner), acc => Acc, class => Class,
432
:-(
reason => Reason, stacktrace => Stacktrace}),
433
:-(
{error, {Class, Reason}}
434 end,
435 30 {ok, Acc}.
436
437 -spec config_metrics(mongooseim:host_type()) -> [{gen_mod:opt_key(), gen_mod:opt_value()}].
438 config_metrics(HostType) ->
439 12 mongoose_module_metrics:opts_for_module(HostType, ?MODULE, [backend]).
440
441 -spec disco_local_features(mongoose_disco:feature_acc(),
442 map(),
443 map()) -> {ok, mongoose_disco:feature_acc()}.
444 disco_local_features(Acc = #{node := <<>>}, _, _) ->
445 1 {ok, mongoose_disco:add_features([?NS_ESL_TOKEN_AUTH], Acc)};
446 disco_local_features(Acc, _, _) ->
447
:-(
{ok, Acc}.
Line Hits Source