]> granicus.if.org Git - ejabberd/commitdiff
Support OAUTH client authentication
authorAlexey Shchepin <alexey@process-one.net>
Fri, 27 Sep 2019 17:36:35 +0000 (20:36 +0300)
committerAlexey Shchepin <alexey@process-one.net>
Fri, 27 Sep 2019 17:36:58 +0000 (20:36 +0300)
include/ejabberd_oauth.hrl
sql/lite.sql
sql/mysql.sql
sql/pg.sql
src/ejabberd_oauth.erl
src/ejabberd_oauth_mnesia.erl
src/ejabberd_oauth_rest.erl
src/ejabberd_oauth_sql.erl
src/ejabberd_option.erl
src/ejabberd_options.erl

index 51e77636f322b0d47431e5204310dd53f33d15a4..9254da1e53bf6da0513a6d12a676d6d247bdbf96 100644 (file)
           scope = []               :: [binary()] | '_',
           expire                   :: integer() | '$1' | '_'
          }).
+
+-record(oauth_client, {
+          client = <<"">>          :: binary() | '_',
+          secret = <<"">>          :: binary() | '_',
+          grant_type = password    :: password | '_',
+          options                  :: [any()] | '_'
+         }).
index e8755029c7b009729db2932f8ac861061be72581..c77922c2088cdfcd521c662c5f260c0ad823ea6b 100644 (file)
@@ -338,6 +338,13 @@ CREATE TABLE oauth_token (
     expire bigint NOT NULL
 );
 
+CREATE TABLE oauth_client (
+    client text PRIMARY KEY,
+    secret text NOT NULL,
+    grant_type text NOT NULL,
+    options text NOT NULL
+);
+
 CREATE TABLE route (
     domain text NOT NULL,
     server_host text NOT NULL,
index a05f8c86cacb59c32f6d8bd856514d2b4a835016..7f415a2e465c2c2ac5063071203a651d98a487aa 100644 (file)
@@ -354,6 +354,13 @@ CREATE TABLE oauth_token (
     expire bigint NOT NULL
 ) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
 
+CREATE TABLE oauth_client (
+    client varchar(191) NOT NULL PRIMARY KEY,
+    secret text NOT NULL,
+    grant_type text NOT NULL,
+    options text NOT NULL
+) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
 CREATE TABLE route (
     domain text NOT NULL,
     server_host text NOT NULL,
index eae98d3f0358e09a48ecc6e6192154d504e2c7c4..0f87fd5d820385c7ca6a94d6d24bcbfe348f1211 100644 (file)
@@ -358,6 +358,13 @@ CREATE TABLE oauth_token (
 
 CREATE UNIQUE INDEX i_oauth_token_token ON oauth_token USING btree (token);
 
+CREATE TABLE oauth_client (
+    client text PRIMARY KEY,
+    secret text NOT NULL,
+    grant_type text NOT NULL,
+    options text NOT NULL
+);
+
 CREATE TABLE route (
     domain text NOT NULL,
     server_host text NOT NULL,
index d9b16c70ed28fffc5621c390fc81e3cc3a5e63f0..4060b4b7b09d264c0f5d8f12878a1a5812db21d3 100644 (file)
@@ -49,7 +49,8 @@
          verify_resowner_scope/3]).
 
 -export([get_commands_spec/0,
-         oauth_issue_token/3, oauth_list_tokens/0, oauth_revoke_token/1]).
+         oauth_issue_token/3, oauth_list_tokens/0, oauth_revoke_token/1,
+         oauth_add_client/3, oauth_remove_client/1]).
 
 -include("xmpp.hrl").
 -include("logger.hrl").
@@ -97,6 +98,22 @@ get_commands_spec() ->
                         policy = restricted,
                         result = {tokens, {list, {token, {tuple, [{token, string}, {user, string}, {scope, string}, {expires_in, string}]}}}},
                         result_desc = "List of remaining tokens"
+                       },
+     #ejabberd_commands{name = oauth_add_client, tags = [oauth],
+                        desc = "Add OAUTH client_id",
+                        module = ?MODULE, function = oauth_add_client,
+                        args = [{client_id, binary},
+                                {secret, binary},
+                                {grant_type, binary}],
+                        policy = restricted,
+                        result = {res, restuple}
+                       },
+     #ejabberd_commands{name = oauth_remove_client, tags = [oauth],
+                        desc = "Remove OAUTH client_id",
+                        module = ?MODULE, function = oauth_remove_client,
+                        args = [{client_id, binary}],
+                        policy = restricted,
+                        result = {res, restuple}
                        }
     ].
 
@@ -129,6 +146,24 @@ oauth_revoke_token(Token) ->
     ok = mnesia:dirty_delete(oauth_token, list_to_binary(Token)),
     oauth_list_tokens().
 
+oauth_add_client(Client, Secret, SGrantType) ->
+    case SGrantType of
+        <<"password">> ->
+            DBMod = get_db_backend(),
+            DBMod:store_client(#oauth_client{client = Client,
+                                             secret = Secret,
+                                             grant_type = password,
+                                             options = []}),
+            {ok, []};
+        _ ->
+            {error, "Unsupported grant type"}
+    end.
+
+oauth_remove_client(Client) ->
+    DBMod = get_db_backend(),
+    DBMod:remove_client(Client),
+    {ok, []}.
+
 config_reloaded() ->
     DBMod = get_db_backend(),
     case init_cache(DBMod) of
@@ -535,48 +570,89 @@ process(_Handlers,
     end;
 process(_Handlers,
        #request{method = 'POST', q = Q, lang = _Lang,
+                 auth = HTTPAuth,
                 path = [_, <<"token">>]}) ->
-    case proplists:get_value(<<"grant_type">>, Q, <<"">>) of
-      <<"password">> ->
-        SScope = proplists:get_value(<<"scope">>, Q, <<"">>),
-        StringJID = proplists:get_value(<<"username">>, Q, <<"">>),
-        #jid{user = Username, server = Server} = jid:decode(StringJID),
-        Password = proplists:get_value(<<"password">>, Q, <<"">>),
-        Scope = str:tokens(SScope, <<" ">>),
-        TTL = proplists:get_value(<<"ttl">>, Q, <<"">>),
-        ExpiresIn = case TTL of
-                        <<>> -> undefined;
-                        _ -> binary_to_integer(TTL)
+    Access =
+        case ejabberd_option:oauth_client_id_check() of
+            allow ->
+                case proplists:get_value(<<"grant_type">>, Q, <<"">>) of
+                    <<"password">> ->
+                        password;
+                    _ ->
+                        unsupported_grant_type
+                end;
+            deny ->
+                deny;
+            db ->
+                {ClientID, Secret} =
+                    case HTTPAuth of
+                        {ClientID1, Secret1} ->
+                            {ClientID1, Secret1};
+                        _ ->
+                            ClientID1 = proplists:get_value(
+                                          <<"client_id">>, Q, <<"">>),
+                            Secret1 = proplists:get_value(
+                                        <<"client_secret">>, Q, <<"">>),
+                            {ClientID1, Secret1}
                     end,
-        case oauth2:authorize_password({Username, Server},
-                                       Scope,
-                                       {password, Password}) of
-            {ok, {_AppContext, Authorization}} ->
-                {ok, {_AppContext2, Response}} =
-                    oauth2:issue_token(Authorization, [{expiry_time, ExpiresIn} || ExpiresIn /= undefined ]),
-                {ok, AccessToken} = oauth2_response:access_token(Response),
-                {ok, Type} = oauth2_response:token_type(Response),
-                %%Ugly: workardound to return the correct expirity time, given than oauth2 lib doesn't really have
-                %%per-case expirity time.
-                Expires = case ExpiresIn of
-                              undefined ->
-                                 {ok, Ex} = oauth2_response:expires_in(Response),
-                                 Ex;
-                              _ ->
-                                ExpiresIn
-                          end,
-                {ok, VerifiedScope} = oauth2_response:scope(Response),
-                json_response(200, {[
-                   {<<"access_token">>, AccessToken},
-                   {<<"token_type">>, Type},
-                   {<<"scope">>, str:join(VerifiedScope, <<" ">>)},
-                   {<<"expires_in">>, Expires}]});
-            {error, Error} when is_atom(Error) ->
-                json_error(400, <<"invalid_grant">>, Error)
-        end;
-        _OtherGrantType ->
-            json_error(400, <<"unsupported_grant_type">>, unsupported_grant_type)
-  end;
+                DBMod = get_db_backend(),
+                case DBMod:lookup_client(ClientID) of
+                    {ok, #oauth_client{secret = Secret} = Client} ->
+                        case proplists:get_value(<<"grant_type">>, Q, <<"">>) of
+                            <<"password">> when
+                            Client#oauth_client.grant_type == password ->
+                                password;
+                            _ ->
+                                unsupported_grant_type
+                        end;
+                    _ ->
+                        deny
+                end
+        end,
+    case Access of
+        password ->
+            SScope = proplists:get_value(<<"scope">>, Q, <<"">>),
+            StringJID = proplists:get_value(<<"username">>, Q, <<"">>),
+            #jid{user = Username, server = Server} = jid:decode(StringJID),
+            Password = proplists:get_value(<<"password">>, Q, <<"">>),
+            Scope = str:tokens(SScope, <<" ">>),
+            TTL = proplists:get_value(<<"ttl">>, Q, <<"">>),
+            ExpiresIn = case TTL of
+                            <<>> -> undefined;
+                            _ -> binary_to_integer(TTL)
+                        end,
+            case oauth2:authorize_password({Username, Server},
+                                           Scope,
+                                           {password, Password}) of
+                {ok, {_AppContext, Authorization}} ->
+                    {ok, {_AppContext2, Response}} =
+                        oauth2:issue_token(Authorization, [{expiry_time, ExpiresIn} || ExpiresIn /= undefined ]),
+                    {ok, AccessToken} = oauth2_response:access_token(Response),
+                    {ok, Type} = oauth2_response:token_type(Response),
+                    %%Ugly: workardound to return the correct expirity time, given than oauth2 lib doesn't really have
+                    %%per-case expirity time.
+                    Expires = case ExpiresIn of
+                                  undefined ->
+                                      {ok, Ex} = oauth2_response:expires_in(Response),
+                                      Ex;
+                                  _ ->
+                                      ExpiresIn
+                              end,
+                    {ok, VerifiedScope} = oauth2_response:scope(Response),
+                    json_response(200, {[
+                                         {<<"access_token">>, AccessToken},
+                                         {<<"token_type">>, Type},
+                                         {<<"scope">>, str:join(VerifiedScope, <<" ">>)},
+                                         {<<"expires_in">>, Expires}]});
+                {error, Error} when is_atom(Error) ->
+                    json_error(400, <<"invalid_grant">>, Error)
+            end;
+        unsupported_grant_type ->
+            json_error(400, <<"unsupported_grant_type">>,
+                       unsupported_grant_type);
+        deny ->
+            ejabberd_web:error(not_allowed)
+    end;
 
 process(_Handlers, _Request) ->
     ejabberd_web:error(not_found).
index dcc70505c553af9aa49074891d9bd1ed0567852c..de851f1ea603d4ab78467355d1bb055e2c0879b0 100644 (file)
@@ -31,6 +31,9 @@
          store/1,
          lookup/1,
          clean/1,
+         lookup_client/1,
+         store_client/1,
+         remove_client/1,
         use_cache/0]).
 
 -include("ejabberd_oauth.hrl").
@@ -40,6 +43,10 @@ init() ->
                         [{disc_only_copies, [node()]},
                          {attributes,
                           record_info(fields, oauth_token)}]),
+    ejabberd_mnesia:create(?MODULE, oauth_client,
+                        [{disc_copies, [node()]},
+                         {attributes,
+                          record_info(fields, oauth_client)}]),
     ok.
 
 use_cache() ->
@@ -71,3 +78,17 @@ clean(TS) ->
                lists:foreach(fun mnesia:delete_object/1, Ts)
         end,
     mnesia:async_dirty(F).
+
+lookup_client(ClientID) ->
+    case catch mnesia:dirty_read(oauth_client, ClientID) of
+        [R] ->
+            {ok, R};
+        _ ->
+            error
+    end.
+
+remove_client(ClientID) ->
+    mnesia:dirty_delete(oauth_client, ClientID).
+
+store_client(R) ->
+    mnesia:dirty_write(R).
index 8ebfecf5a8f93e48340d90c37e906e9cf196ae83..b15fc904b1e577187976a9f2086cf6a6d57d42f4 100644 (file)
@@ -30,7 +30,9 @@
 -export([init/0,
          store/1,
          lookup/1,
-         clean/1]).
+         clean/1,
+         lookup_client/1,
+         store_client/1]).
 
 -include("ejabberd_oauth.hrl").
 -include("logger.hrl").
@@ -88,3 +90,50 @@ clean(_TS) ->
 path(Path) ->
     Base = ejabberd_option:ext_api_path_oauth(),
     <<Base/binary, "/", Path/binary>>.
+
+store_client(#oauth_client{client = Client,
+                           secret = Secret,
+                           grant_type = GrantType} = R) ->
+    Path = path(<<"store_client">>),
+    %% Retry 2 times, with a backoff of 500millisec
+    SGrantType =
+        case GrantType of
+            password -> <<"password">>
+        end,
+    case rest:with_retry(
+           post,
+           [ejabberd_config:get_myname(), Path, [],
+            {[{<<"client">>, Client},
+              {<<"secret">>, Secret},
+              {<<"grant_type">>, SGrantType},
+              {<<"options">>, []}
+             ]}], 2, 500) of
+        {ok, Code, _} when Code == 200 orelse Code == 201 ->
+            ok;
+        Err ->
+            ?ERROR_MSG("Failed to store oauth record ~p: ~p", [R, Err]),
+            {error, db_failure}
+    end.
+
+lookup_client(Client) ->
+    Path = path(<<"lookup_client">>),
+    case rest:with_retry(post, [ejabberd_config:get_myname(), Path, [],
+                                {[{<<"client">>, Client}]}],
+                         2, 500) of
+        {ok, 200, {Data}} ->
+            Secret = proplists:get_value(<<"secret">>, Data, <<>>),
+            SGrantType = proplists:get_value(<<"grant_type">>, Data, <<>>),
+            GrantType =
+                case SGrantType of
+                    <<"password">> -> password
+                end,
+            {ok, #oauth_client{client = Client,
+                               secret = Secret,
+                               grant_type = GrantType,
+                               options = []}};
+        {ok, 404, _Resp} ->
+            error;
+        Other ->
+            ?ERROR_MSG("Unexpected response for oauth lookup: ~p", [Other]),
+           error
+    end.
index 8ce2bc574baa32c7e98eb87e7f14b100caecf502..724017af4e5fcd0c6a2f97a2f830a53169471fa7 100644 (file)
 -export([init/0,
          store/1,
          lookup/1,
-         clean/1]).
+         clean/1,
+         lookup_client/1,
+         store_client/1,
+         remove_client/1]).
 
 -include("ejabberd_oauth.hrl").
 -include("ejabberd_sql_pt.hrl").
@@ -80,3 +83,46 @@ clean(TS) ->
       ejabberd_config:get_myname(),
       ?SQL("delete from oauth_token where expire < %(TS)d")).
 
+lookup_client(Client) ->
+    case ejabberd_sql:sql_query(
+           ejabberd_config:get_myname(),
+           ?SQL("select @(secret)s, @(grant_type)s"
+                " from oauth_client where client=%(Client)s")) of
+        {selected, [{Secret, SGrantType}]} ->
+            GrantType =
+                case SGrantType of
+                    <<"password">> -> password
+                end,
+            {ok, #oauth_client{client = Client,
+                               secret = Secret,
+                               grant_type = GrantType,
+                               options = []}};
+        _ ->
+            error
+    end.
+
+store_client(#oauth_client{client = Client,
+                           secret = Secret,
+                           grant_type = GrantType}) ->
+    SGrantType =
+        case GrantType of
+            password -> <<"password">>
+        end,
+    SOptions = <<"">>,
+    case ?SQL_UPSERT(
+           ejabberd_config:get_myname(),
+           "oauth_client",
+           ["!client=%(Client)s",
+            "secret=%(Secret)s",
+            "grant_type=%(SGrantType)s",
+            "options=%(SOptions)s"]) of
+       ok ->
+           ok;
+       _ ->
+           {error, db_failure}
+    end.
+
+remove_client(Client) ->
+    ejabberd_sql:sql_query(
+      ejabberd_config:get_myname(),
+      ?SQL("delete from oauth_client where client=%(Client)s")).
index e559db20ff1ffdbe3d06fb1cfb2a3851123d65bd..94e79d80dbc0a1e765c90dda642adc583f66496d 100644 (file)
@@ -83,6 +83,7 @@
 -export([oauth_cache_life_time/0]).
 -export([oauth_cache_missed/0]).
 -export([oauth_cache_size/0]).
+-export([oauth_client_id_check/0, oauth_client_id_check/1]).
 -export([oauth_db_type/0]).
 -export([oauth_expire/0]).
 -export([oauth_use_cache/0]).
@@ -620,6 +621,13 @@ oauth_cache_missed() ->
 oauth_cache_size() ->
     ejabberd_config:get_option({oauth_cache_size, global}).
 
+-spec oauth_client_id_check() -> 'allow' | 'db' | 'deny'.
+oauth_client_id_check() ->
+    oauth_client_id_check(global).
+-spec oauth_client_id_check(global | binary()) -> 'allow' | 'db' | 'deny'.
+oauth_client_id_check(Host) ->
+    ejabberd_config:get_option({oauth_client_id_check, Host}).
+
 -spec oauth_db_type() -> atom().
 oauth_db_type() ->
     ejabberd_config:get_option({oauth_db_type, global}).
index 8468d181b84d2d929e1251fbf1f9de038226182b..7bfa06ee6da668e83a58bad9ed2422bfa8e3e3f0 100644 (file)
@@ -234,6 +234,8 @@ opt_type(oauth_expire) ->
     econf:non_neg_int();
 opt_type(oauth_use_cache) ->
     econf:bool();
+opt_type(oauth_client_id_check) ->
+    econf:enum([allow, deny, db]);
 opt_type(oom_killer) ->
     econf:bool();
 opt_type(oom_queue) ->
@@ -546,6 +548,7 @@ options() ->
      {oauth_expire, 4294967},
      {oauth_use_cache,
       fun(Host) -> ejabberd_config:get_option({use_cache, Host}) end},
+     {oauth_client_id_check, allow},
      {oom_killer, true},
      {oom_queue, 10000},
      {oom_watermark, 80},