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