Skip to content

Commit bbffde5

Browse files
committed
Add API key support
1 parent 836b668 commit bbffde5

17 files changed

+216
-40
lines changed

README.md

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ The following features are supported:
132132
- Binary fields supported with base64 encoding
133133
- Spatial/GIS fields and filters supported with WKT and GeoJSON
134134
- Generate API documentation using OpenAPI tools
135-
- Authentication via JWT token or username/password
135+
- Authentication via API key, JWT token or username/password
136136
- Database connection parameters may depend on authentication
137137
- Support for reading database structure in JSON
138138
- Support for modifying database structure using REST endpoint
@@ -627,6 +627,8 @@ You can enable the following middleware using the "middlewares" config parameter
627627
- "cors": Support for CORS requests (enabled by default)
628628
- "xsrf": Block XSRF attacks using the 'Double Submit Cookie' method
629629
- "ajaxOnly": Restrict non-AJAX requests to prevent XSRF attacks
630+
- "apiKeyAuth": Support for "API Key Authentication"
631+
- "apiKeyDbAuth": Support for "API Key Database Authentication"
630632
- "dbAuth": Support for "Database Authentication"
631633
- "jwtAuth": Support for "JWT Authentication"
632634
- "basicAuth": Support for "Basic Authentication"
@@ -659,6 +661,13 @@ You can tune the middleware behavior using middleware specific configuration par
659661
- "ajaxOnly.excludeMethods": The methods that do not require AJAX ("OPTIONS,GET")
660662
- "ajaxOnly.headerName": The name of the required header ("X-Requested-With")
661663
- "ajaxOnly.headerValue": The value of the required header ("XMLHttpRequest")
664+
- "apiKeyAuth.mode": Set to "optional" if you want to allow anonymous access ("required")
665+
- "apiKeyAuth.header": The name of the API key header ("X-API-Key")
666+
- "apiKeyAuth.keys": List of API keys that are valid ("")
667+
- "apiKeyDbAuth.mode": Set to "optional" if you want to allow anonymous access ("required")
668+
- "apiKeyDbAuth.header": The name of the API key header ("X-API-Key")
669+
- "apiKeyDbAuth.usersTable": The table that is used to store the users in ("users")
670+
- "apiKeyDbAuth.apiKeyColumn": The users table column that holds the API key ("api_key")
662671
- "dbAuth.mode": Set to "optional" if you want to allow anonymous access ("required")
663672
- "dbAuth.usersTable": The table that is used to store the users in ("users")
664673
- "dbAuth.usernameColumn": The users table column that holds usernames ("username")
@@ -718,18 +727,43 @@ In the sections below you find more information on the built-in middleware.
718727

719728
### Authentication
720729

721-
Currently there are three types of authentication supported. They all store the authenticated user in the `$_SESSION` super global.
730+
Currently there are five types of authentication supported. They all store the authenticated user in the `$_SESSION` super global.
722731
This variable can be used in the authorization handlers to decide wether or not sombeody should have read or write access to certain tables, columns or records.
723732
The following overview shows the kinds of authentication middleware that you can enable.
724733

725-
| Name | Middleware | Authenticated via | Users are stored in | Session variable |
726-
| -------- | ---------- | ---------------------- | ------------------- | ----------------------- |
727-
| Database | dbAuth | '/login' endpoint | database table | `$_SESSION['user']` |
728-
| Basic | basicAuth | 'Authorization' header | '.htpasswd' file | `$_SESSION['username']` |
729-
| JWT | jwtAuth | 'Authorization' header | identity provider | `$_SESSION['claims']` |
734+
| Name | Middleware | Authenticated via | Users are stored in | Session variable |
735+
| ---------- | ------------ | ---------------------- | ------------------- | ----------------------- |
736+
| API key | apiKeyAuth | 'X-API-Key' header | configuration | `$_SESSION['apiKey']` |
737+
| API key DB | apiKeyDbAuth | 'X-API-Key' header | database table | `$_SESSION['apiUser']` |
738+
| Database | dbAuth | '/login' endpoint | database table | `$_SESSION['user']` |
739+
| Basic | basicAuth | 'Authorization' header | '.htpasswd' file | `$_SESSION['username']` |
740+
| JWT | jwtAuth | 'Authorization' header | identity provider | `$_SESSION['claims']` |
730741

731742
Below you find more information on each of the authentication types.
732743

744+
#### API key authentication
745+
746+
API key authentication works by sending an API key in a request header.
747+
The header name defaults to "X-API-Key" and can be configured using the 'apiKeyAuth.header' configuration parameter.
748+
Valid API keys must be configured using the 'apiKeyAuth.keys' configuration parameter (comma seperated list).
749+
750+
X-API-Key: 02c042aa-c3c2-4d11-9dae-1a6e230ea95e
751+
752+
The authenticated API key will be stored in the `$_SESSION['apiKey']` variable.
753+
754+
Note that the API key authentication does not require or use sessions (cookies).
755+
756+
#### API key database authentication
757+
758+
API key database authentication works by sending an API key in a request header "X-API-Key" (the name is configurable).
759+
Valid API keys are read from the database from the column "api_key" of the "users" table (both names are configurable).
760+
761+
X-API-Key: 02c042aa-c3c2-4d11-9dae-1a6e230ea95e
762+
763+
The authenticated user will be stored in the `$_SESSION['apiUser']` variable.
764+
765+
Note that the API key database authentication does not require or use sessions (cookies).
766+
733767
#### Database authentication
734768

735769
The database authentication middleware defines three new routes:
@@ -746,7 +780,7 @@ A user can be logged in by sending it's username and password to the login endpo
746780
The authenticated user (with all it's properties) will be stored in the `$_SESSION['user']` variable.
747781
The user can be logged out by sending a POST request with an empty body to the logout endpoint.
748782
The passwords are stored as hashes in the password column in the users table. You can register a new user
749-
using the register endpoint, but this functionality must be turned on using the "dbAuth.regsiterUser"
783+
using the register endpoint, but this functionality must be turned on using the "dbAuth.registerUser"
750784
configuration parameter.
751785

752786
It is IMPORTANT to restrict access to the users table using the 'authorization' middleware, otherwise all
@@ -762,7 +796,7 @@ Note that this middleware uses session cookies and stores the logged in state on
762796
#### Basic authentication
763797

764798
The Basic type supports a file (by default '.htpasswd') that holds the users and their (hashed) passwords separated by a colon (':').
765-
When the passwords are entered in plain text they fill be automatically hashed.
799+
When the passwords are entered in plain text they will be automatically hashed.
766800
The authenticated username will be stored in the `$_SESSION['username']` variable.
767801
You need to send an "Authorization" header containing a base64 url encoded version of your colon separated username and password, after the word "Basic".
768802

src/Tqdev/PhpCrudApi/Api.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
use Tqdev\PhpCrudApi\Controller\StatusController;
1818
use Tqdev\PhpCrudApi\Database\GenericDB;
1919
use Tqdev\PhpCrudApi\GeoJson\GeoJsonService;
20+
use Tqdev\PhpCrudApi\Middleware\ApiKeyAuthMiddleware;
21+
use Tqdev\PhpCrudApi\Middleware\ApiKeyDbAuthMiddleware;
2022
use Tqdev\PhpCrudApi\Middleware\AuthorizationMiddleware;
2123
use Tqdev\PhpCrudApi\Middleware\BasicAuthMiddleware;
2224
use Tqdev\PhpCrudApi\Middleware\CorsMiddleware;
@@ -74,6 +76,12 @@ public function __construct(Config $config)
7476
case 'firewall':
7577
new FirewallMiddleware($router, $responder, $properties);
7678
break;
79+
case 'apiKeyAuth':
80+
new ApiKeyAuthMiddleware($router, $responder, $properties);
81+
break;
82+
case 'apiKeyDbAuth':
83+
new ApiKeyDbAuthMiddleware($router, $responder, $properties, $reflection, $db);
84+
break;
7785
case 'basicAuth':
7886
new BasicAuthMiddleware($router, $responder, $properties);
7987
break;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Tqdev\PhpCrudApi\Middleware;
4+
5+
use Psr\Http\Message\ResponseInterface;
6+
use Psr\Http\Message\ServerRequestInterface;
7+
use Psr\Http\Server\RequestHandlerInterface;
8+
use Tqdev\PhpCrudApi\Controller\Responder;
9+
use Tqdev\PhpCrudApi\Middleware\Base\Middleware;
10+
use Tqdev\PhpCrudApi\Record\ErrorCode;
11+
use Tqdev\PhpCrudApi\RequestUtils;
12+
13+
class ApiKeyAuthMiddleware extends Middleware
14+
{
15+
public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface
16+
{
17+
$headerName = $this->getProperty('header', 'X-API-Key');
18+
$apiKey = RequestUtils::getHeader($request, $headerName);
19+
if ($apiKey) {
20+
$apiKeys = $this->getArrayProperty('keys', '');
21+
if (!in_array($apiKey, $apiKeys)) {
22+
return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $apiKey);
23+
}
24+
} else {
25+
$authenticationMode = $this->getProperty('mode', 'required');
26+
if ($authenticationMode == 'required') {
27+
return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, '');
28+
}
29+
}
30+
$_SESSION['apiKey'] = $apiKey;
31+
return $next->handle($request);
32+
}
33+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace Tqdev\PhpCrudApi\Middleware;
4+
5+
use Psr\Http\Message\ResponseInterface;
6+
use Psr\Http\Message\ServerRequestInterface;
7+
use Psr\Http\Server\RequestHandlerInterface;
8+
use Tqdev\PhpCrudApi\Column\ReflectionService;
9+
use Tqdev\PhpCrudApi\Controller\Responder;
10+
use Tqdev\PhpCrudApi\Database\GenericDB;
11+
use Tqdev\PhpCrudApi\Middleware\Base\Middleware;
12+
use Tqdev\PhpCrudApi\Middleware\Router\Router;
13+
use Tqdev\PhpCrudApi\Record\Condition\ColumnCondition;
14+
use Tqdev\PhpCrudApi\Record\ErrorCode;
15+
use Tqdev\PhpCrudApi\Record\OrderingInfo;
16+
use Tqdev\PhpCrudApi\RequestUtils;
17+
18+
class ApiKeyDbAuthMiddleware extends Middleware
19+
{
20+
private $reflection;
21+
private $db;
22+
private $ordering;
23+
24+
public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection, GenericDB $db)
25+
{
26+
parent::__construct($router, $responder, $properties);
27+
$this->reflection = $reflection;
28+
$this->db = $db;
29+
$this->ordering = new OrderingInfo();
30+
}
31+
32+
public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface
33+
{
34+
$user = false;
35+
$headerName = $this->getProperty('header', 'X-API-Key');
36+
$apiKey = RequestUtils::getHeader($request, $headerName);
37+
if ($apiKey) {
38+
$tableName = $this->getProperty('usersTable', 'users');
39+
$table = $this->reflection->getTable($tableName);
40+
$apiKeyColumnName = $this->getProperty('apiKeyColumn', 'api_key');
41+
$apiKeyColumn = $table->getColumn($apiKeyColumnName);
42+
$condition = new ColumnCondition($apiKeyColumn, 'eq', $apiKey);
43+
$columnNames = $table->getColumnNames();
44+
$columnOrdering = $this->ordering->getDefaultColumnOrdering($table);
45+
$users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1);
46+
if (count($users) < 1) {
47+
return $this->responder->error(ErrorCode::AUTHENTICATION_FAILED, $apiKey);
48+
}
49+
$user = $users[0];
50+
} else {
51+
$authenticationMode = $this->getProperty('mode', 'required');
52+
if ($authenticationMode == 'required') {
53+
return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, '');
54+
}
55+
}
56+
$_SESSION['apiUser'] = $user;
57+
return $next->handle($request);
58+
}
59+
}

tests/config/base.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
'username' => 'incorrect_username',
55
'password' => 'incorrect_password',
66
'controllers' => 'records,columns,cache,openapi,geojson,status',
7-
'middlewares' => 'sslRedirect,xml,cors,json,reconnect,dbAuth,jwtAuth,basicAuth,authorization,sanitation,validation,ipAddress,multiTenancy,pageLimits,joinLimits,customization',
7+
'middlewares' => 'sslRedirect,xml,cors,json,reconnect,apiKeyAuth,apiKeyDbAuth,dbAuth,jwtAuth,basicAuth,authorization,sanitation,validation,ipAddress,multiTenancy,pageLimits,joinLimits,customization',
8+
'apiKeyAuth.mode' => 'optional',
9+
'apiKeyAuth.keys' => '123456789abc',
10+
'apiKeyDbAuth.mode' => 'optional',
11+
'apiKeyDbAuth.header' => 'X-API-Key-DB',
812
'dbAuth.mode' => 'optional',
913
'dbAuth.returnedColumns' => 'id,username,password',
1014
'dbAuth.registerUser' => '1',
@@ -24,7 +28,7 @@
2428
return 'php-crud-api';
2529
},
2630
'authorization.tableHandler' => function ($operation, $tableName) {
27-
return !($tableName == 'invisibles' && !isset($_SESSION['claims']['name']) && empty($_SESSION['username']) && empty($_SESSION['user']));
31+
return !($tableName == 'invisibles' && !isset($_SESSION['claims']['name']) && empty($_SESSION['username']) && empty($_SESSION['user']) && empty($_SESSION['apiKey']) && empty($_SESSION['apiUser']));
2832
},
2933
'authorization.columnHandler' => function ($operation, $tableName, $columnName) {
3034
return !($columnName == 'invisible');

tests/fixtures/blog_mysql.sql

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,14 @@ CREATE TABLE `users` (
8888
`id` int(11) NOT NULL AUTO_INCREMENT,
8989
`username` varchar(255) NOT NULL,
9090
`password` varchar(255) NOT NULL,
91+
`api_key` varchar(255) NULL,
9192
`location` point,
9293
PRIMARY KEY (`id`)
9394
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
9495

95-
INSERT INTO `users` (`username`, `password`, `location`) VALUES
96-
('user1', 'pass1', NULL),
97-
('user2', '$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL);
96+
INSERT INTO `users` (`username`, `password`, `api_key`, `location`) VALUES
97+
('user1', 'pass1', '123456789abc', NULL),
98+
('user2', '$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL, NULL);
9899

99100
DROP TABLE IF EXISTS `countries`;
100101
CREATE TABLE `countries` (

tests/fixtures/blog_pgsql.sql

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ CREATE TABLE users (
9999
id serial NOT NULL,
100100
username character varying(255) NOT NULL,
101101
password character varying(255) NOT NULL,
102+
api_key character varying(255) NULL,
102103
location geometry
103104
);
104105

@@ -231,9 +232,9 @@ INSERT INTO "tags" ("name", "is_important") VALUES
231232
-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: postgres
232233
--
233234

234-
INSERT INTO "users" ("username", "password", "location") VALUES
235-
('user1', 'pass1', NULL),
236-
('user2', '$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL);
235+
INSERT INTO "users" ("username", "password", "api_key", "location") VALUES
236+
('user1', 'pass1', '123456789abc', NULL),
237+
('user2', '$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL, NULL);
237238

238239
--
239240
-- Data for Name: countries; Type: TABLE DATA; Schema: public; Owner: postgres

tests/fixtures/blog_sqlite.sql

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,12 @@ CREATE TABLE "users" (
7979
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
8080
"username" varchar(255) NOT NULL,
8181
"password" varchar(255) NOT NULL,
82+
"api_key" varchar(255) NULL,
8283
"location" text NULL
8384
);
8485

85-
INSERT INTO "users" ("id", "username", "password", "location") VALUES (1, 'user1', 'pass1', NULL);
86-
INSERT INTO "users" ("id", "username", "password", "location") VALUES (2, 'user2', '$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL);
86+
INSERT INTO "users" ("id", "username", "password", "api_key", "location") VALUES (1, 'user1', 'pass1', '123456789abc', NULL);
87+
INSERT INTO "users" ("id", "username", "password", "api_key", "location") VALUES (2, 'user2', '$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL, NULL);
8788

8889
DROP TABLE IF EXISTS "countries";
8990
CREATE TABLE "countries" (

tests/fixtures/blog_sqlsrv.sql

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ CREATE TABLE [users](
212212
[id] [int] NOT NULL CONSTRAINT [users_id_def] DEFAULT NEXT VALUE FOR [users_id_seq],
213213
[username] [nvarchar](255) NOT NULL,
214214
[password] [nvarchar](255) NOT NULL,
215+
[api_key] [nvarchar](255) NULL,
215216
[location] [geometry],
216217
CONSTRAINT [users_pkey] PRIMARY KEY CLUSTERED([id] ASC)
217218
)
@@ -336,9 +337,9 @@ GO
336337
INSERT [tags] ([name], [is_important]) VALUES (N'important', 1)
337338
GO
338339

339-
INSERT [users] ([username], [password], [location]) VALUES (N'user1', N'pass1', NULL)
340+
INSERT [users] ([username], [password], [api_key], [location]) VALUES (N'user1', N'pass1', N'123456789abc', NULL)
340341
GO
341-
INSERT [users] ([username], [password], [location]) VALUES (N'user2', N'$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL)
342+
INSERT [users] ([username], [password], [api_key], [location]) VALUES (N'user2', N'$2y$10$cg7/nswxVZ0cmVIsMB/pVOh1OfcHScBJGq7Xu4KF9dFEQgRZ8HWe.', NULL, NULL)
342343
GO
343344

344345
INSERT [countries] ([name], [shape]) VALUES (N'Left', N'POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))')

tests/functional/001_records/027_list_example_from_readme_users_only.log

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ GET /records/posts?join=users&filter=id,eq,1
33
===
44
200
55
Content-Type: application/json; charset=utf-8
6-
Content-Length: 136
6+
Content-Length: 161
77

8-
{"records":[{"id":1,"user_id":{"id":1,"username":"user1","password":"pass1","location":null},"category_id":1,"content":"blog started"}]}
8+
{"records":[{"id":1,"user_id":{"id":1,"username":"user1","password":"pass1","api_key":"123456789abc","location":null},"category_id":1,"content":"blog started"}]}

tests/functional/001_records/028_read_example_from_readme_users_only.log

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ GET /records/posts/1?join=users
33
===
44
200
55
Content-Type: application/json; charset=utf-8
6-
Content-Length: 122
6+
Content-Length: 147
77

8-
{"id":1,"user_id":{"id":1,"username":"user1","password":"pass1","location":null},"category_id":1,"content":"blog started"}
8+
{"id":1,"user_id":{"id":1,"username":"user1","password":"pass1","api_key":"123456789abc","location":null},"category_id":1,"content":"blog started"}

tests/functional/001_records/082_read_users_as_geojson.log

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
skip-for-sqlite: no support for geometry functions (spatialite)
22
===
3-
GET /geojson/users/1?exclude=password
3+
GET /geojson/users/1?exclude=password,api_key
44
===
55
200
66
Content-Type: application/json; charset=utf-8
77
Content-Length: 109
88

99
{"type":"Feature","id":1,"properties":{"username":"user1"},"geometry":{"type":"Point","coordinates":[30,20]}}
1010
===
11-
GET /geojson/users/2?exclude=password
11+
GET /geojson/users/2?exclude=password,api_key
1212
===
1313
200
1414
Content-Type: application/json; charset=utf-8

tests/functional/001_records/083_list_users_as_geojson.log

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,38 @@
11
skip-for-sqlite: no support for geometry functions (spatialite)
22
===
3-
GET /geojson/users?exclude=password
3+
GET /geojson/users?exclude=password,api_key
44
===
55
200
66
Content-Type: application/json; charset=utf-8
77
Content-Length: 227
88

99
{"type":"FeatureCollection","features":[{"type":"Feature","id":1,"properties":{"username":"user1"},"geometry":{"type":"Point","coordinates":[30,20]}},{"type":"Feature","id":2,"properties":{"username":"user2"},"geometry":null}]}
1010
===
11-
GET /geojson/users?exclude=password&geometry=location
11+
GET /geojson/users?exclude=password,api_key&geometry=location
1212
===
1313
200
1414
Content-Type: application/json; charset=utf-8
1515
Content-Length: 227
1616

1717
{"type":"FeatureCollection","features":[{"type":"Feature","id":1,"properties":{"username":"user1"},"geometry":{"type":"Point","coordinates":[30,20]}},{"type":"Feature","id":2,"properties":{"username":"user2"},"geometry":null}]}
1818
===
19-
GET /geojson/users?exclude=password&geometry=notlocation
19+
GET /geojson/users?exclude=password,api_key&geometry=notlocation
2020
===
2121
200
2222
Content-Type: application/json; charset=utf-8
2323
Content-Length: 235
2424

2525
{"type":"FeatureCollection","features":[{"type":"Feature","id":1,"properties":{"username":"user1","location":"POINT(30 20)"},"geometry":null},{"type":"Feature","id":2,"properties":{"username":"user2","location":null},"geometry":null}]}
2626
===
27-
GET /geojson/users?exclude=password&page=1,1
27+
GET /geojson/users?exclude=password,api_key&page=1,1
2828
===
2929
200
3030
Content-Type: application/json; charset=utf-8
3131
Content-Length: 163
3232

3333
{"type":"FeatureCollection","features":[{"type":"Feature","id":1,"properties":{"username":"user1"},"geometry":{"type":"Point","coordinates":[30,20]}}],"results":2}
3434
===
35-
GET /geojson/users?exclude=password&bbox=29.99,19.99,30.01,20.01
35+
GET /geojson/users?exclude=password,api_key&bbox=29.99,19.99,30.01,20.01
3636
===
3737
200
3838
Content-Type: application/json; charset=utf-8

0 commit comments

Comments
 (0)