howler-api 3.0.0.dev374__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of howler-api might be problematic. Click here for more details.
- howler/__init__.py +0 -0
- howler/actions/__init__.py +168 -0
- howler/actions/add_label.py +111 -0
- howler/actions/add_to_bundle.py +159 -0
- howler/actions/change_field.py +76 -0
- howler/actions/demote.py +160 -0
- howler/actions/example_plugin.py +104 -0
- howler/actions/prioritization.py +93 -0
- howler/actions/promote.py +147 -0
- howler/actions/remove_from_bundle.py +133 -0
- howler/actions/remove_label.py +111 -0
- howler/actions/transition.py +200 -0
- howler/api/__init__.py +249 -0
- howler/api/base.py +88 -0
- howler/api/socket.py +114 -0
- howler/api/v1/__init__.py +97 -0
- howler/api/v1/action.py +372 -0
- howler/api/v1/analytic.py +748 -0
- howler/api/v1/auth.py +382 -0
- howler/api/v1/clue.py +99 -0
- howler/api/v1/configs.py +58 -0
- howler/api/v1/dossier.py +222 -0
- howler/api/v1/help.py +28 -0
- howler/api/v1/hit.py +1181 -0
- howler/api/v1/notebook.py +82 -0
- howler/api/v1/overview.py +191 -0
- howler/api/v1/search.py +788 -0
- howler/api/v1/template.py +206 -0
- howler/api/v1/tool.py +183 -0
- howler/api/v1/user.py +416 -0
- howler/api/v1/utils/__init__.py +0 -0
- howler/api/v1/utils/etag.py +84 -0
- howler/api/v1/view.py +288 -0
- howler/app.py +235 -0
- howler/common/README.md +125 -0
- howler/common/__init__.py +0 -0
- howler/common/classification.py +979 -0
- howler/common/classification.yml +107 -0
- howler/common/exceptions.py +167 -0
- howler/common/loader.py +154 -0
- howler/common/logging/__init__.py +241 -0
- howler/common/logging/audit.py +138 -0
- howler/common/logging/format.py +38 -0
- howler/common/net.py +79 -0
- howler/common/net_static.py +1494 -0
- howler/common/random_user.py +316 -0
- howler/common/swagger.py +117 -0
- howler/config.py +64 -0
- howler/cronjobs/__init__.py +29 -0
- howler/cronjobs/retention.py +61 -0
- howler/cronjobs/rules.py +274 -0
- howler/cronjobs/view_cleanup.py +88 -0
- howler/datastore/README.md +112 -0
- howler/datastore/__init__.py +0 -0
- howler/datastore/bulk.py +72 -0
- howler/datastore/collection.py +2342 -0
- howler/datastore/constants.py +119 -0
- howler/datastore/exceptions.py +41 -0
- howler/datastore/howler_store.py +105 -0
- howler/datastore/migrations/fix_process.py +41 -0
- howler/datastore/operations.py +130 -0
- howler/datastore/schemas.py +90 -0
- howler/datastore/store.py +231 -0
- howler/datastore/support/__init__.py +0 -0
- howler/datastore/support/build.py +215 -0
- howler/datastore/support/schemas.py +90 -0
- howler/datastore/types.py +22 -0
- howler/error.py +91 -0
- howler/external/__init__.py +0 -0
- howler/external/generate_mitre.py +96 -0
- howler/external/generate_sigma_rules.py +31 -0
- howler/external/generate_tlds.py +47 -0
- howler/external/reindex_data.py +66 -0
- howler/external/wipe_databases.py +58 -0
- howler/gunicorn_config.py +25 -0
- howler/healthz.py +47 -0
- howler/helper/__init__.py +0 -0
- howler/helper/azure.py +50 -0
- howler/helper/discover.py +59 -0
- howler/helper/hit.py +236 -0
- howler/helper/oauth.py +247 -0
- howler/helper/search.py +92 -0
- howler/helper/workflow.py +110 -0
- howler/helper/ws.py +378 -0
- howler/odm/README.md +102 -0
- howler/odm/__init__.py +1 -0
- howler/odm/base.py +1543 -0
- howler/odm/charter.txt +146 -0
- howler/odm/helper.py +416 -0
- howler/odm/howler_enum.py +25 -0
- howler/odm/models/__init__.py +0 -0
- howler/odm/models/action.py +33 -0
- howler/odm/models/analytic.py +90 -0
- howler/odm/models/assemblyline.py +48 -0
- howler/odm/models/aws.py +23 -0
- howler/odm/models/azure.py +16 -0
- howler/odm/models/cbs.py +44 -0
- howler/odm/models/config.py +558 -0
- howler/odm/models/dossier.py +33 -0
- howler/odm/models/ecs/__init__.py +0 -0
- howler/odm/models/ecs/agent.py +17 -0
- howler/odm/models/ecs/autonomous_system.py +16 -0
- howler/odm/models/ecs/client.py +149 -0
- howler/odm/models/ecs/cloud.py +141 -0
- howler/odm/models/ecs/code_signature.py +27 -0
- howler/odm/models/ecs/container.py +32 -0
- howler/odm/models/ecs/dns.py +62 -0
- howler/odm/models/ecs/egress.py +10 -0
- howler/odm/models/ecs/elf.py +74 -0
- howler/odm/models/ecs/email.py +122 -0
- howler/odm/models/ecs/error.py +14 -0
- howler/odm/models/ecs/event.py +140 -0
- howler/odm/models/ecs/faas.py +24 -0
- howler/odm/models/ecs/file.py +84 -0
- howler/odm/models/ecs/geo.py +30 -0
- howler/odm/models/ecs/group.py +18 -0
- howler/odm/models/ecs/hash.py +16 -0
- howler/odm/models/ecs/host.py +17 -0
- howler/odm/models/ecs/http.py +37 -0
- howler/odm/models/ecs/ingress.py +12 -0
- howler/odm/models/ecs/interface.py +21 -0
- howler/odm/models/ecs/network.py +30 -0
- howler/odm/models/ecs/observer.py +45 -0
- howler/odm/models/ecs/organization.py +12 -0
- howler/odm/models/ecs/os.py +21 -0
- howler/odm/models/ecs/pe.py +17 -0
- howler/odm/models/ecs/process.py +216 -0
- howler/odm/models/ecs/registry.py +26 -0
- howler/odm/models/ecs/related.py +45 -0
- howler/odm/models/ecs/rule.py +51 -0
- howler/odm/models/ecs/server.py +24 -0
- howler/odm/models/ecs/threat.py +247 -0
- howler/odm/models/ecs/tls.py +58 -0
- howler/odm/models/ecs/url.py +51 -0
- howler/odm/models/ecs/user.py +57 -0
- howler/odm/models/ecs/user_agent.py +20 -0
- howler/odm/models/ecs/vulnerability.py +41 -0
- howler/odm/models/gcp.py +16 -0
- howler/odm/models/hit.py +356 -0
- howler/odm/models/howler_data.py +328 -0
- howler/odm/models/lead.py +24 -0
- howler/odm/models/localized_label.py +13 -0
- howler/odm/models/overview.py +16 -0
- howler/odm/models/pivot.py +40 -0
- howler/odm/models/template.py +24 -0
- howler/odm/models/user.py +83 -0
- howler/odm/models/view.py +34 -0
- howler/odm/random_data.py +888 -0
- howler/odm/randomizer.py +609 -0
- howler/patched.py +5 -0
- howler/plugins/__init__.py +25 -0
- howler/plugins/config.py +123 -0
- howler/remote/__init__.py +0 -0
- howler/remote/datatypes/README.md +355 -0
- howler/remote/datatypes/__init__.py +98 -0
- howler/remote/datatypes/counters.py +63 -0
- howler/remote/datatypes/events.py +66 -0
- howler/remote/datatypes/hash.py +206 -0
- howler/remote/datatypes/lock.py +42 -0
- howler/remote/datatypes/queues/__init__.py +0 -0
- howler/remote/datatypes/queues/comms.py +59 -0
- howler/remote/datatypes/queues/multi.py +32 -0
- howler/remote/datatypes/queues/named.py +93 -0
- howler/remote/datatypes/queues/priority.py +215 -0
- howler/remote/datatypes/set.py +118 -0
- howler/remote/datatypes/user_quota_tracker.py +54 -0
- howler/security/__init__.py +253 -0
- howler/security/socket.py +108 -0
- howler/security/utils.py +185 -0
- howler/services/__init__.py +0 -0
- howler/services/action_service.py +111 -0
- howler/services/analytic_service.py +128 -0
- howler/services/auth_service.py +323 -0
- howler/services/config_service.py +128 -0
- howler/services/dossier_service.py +252 -0
- howler/services/event_service.py +93 -0
- howler/services/hit_service.py +893 -0
- howler/services/jwt_service.py +158 -0
- howler/services/lucene_service.py +286 -0
- howler/services/notebook_service.py +119 -0
- howler/services/overview_service.py +44 -0
- howler/services/template_service.py +45 -0
- howler/services/user_service.py +331 -0
- howler/utils/__init__.py +0 -0
- howler/utils/annotations.py +28 -0
- howler/utils/chunk.py +38 -0
- howler/utils/dict_utils.py +200 -0
- howler/utils/isotime.py +17 -0
- howler/utils/list_utils.py +11 -0
- howler/utils/lucene.py +77 -0
- howler/utils/path.py +27 -0
- howler/utils/socket_utils.py +61 -0
- howler/utils/str_utils.py +256 -0
- howler/utils/uid.py +47 -0
- howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
- howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
- howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
- howler_api-3.0.0.dev374.dist-info/entry_points.txt +8 -0
howler/api/v1/user.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from hashlib import sha256
|
|
3
|
+
from typing import Any, Optional, cast
|
|
4
|
+
|
|
5
|
+
from flask import request
|
|
6
|
+
|
|
7
|
+
import howler.services.user_service as user_service
|
|
8
|
+
from howler.api import bad_request, forbidden, internal_error, make_subapi_blueprint, no_content, not_found, ok
|
|
9
|
+
from howler.api.v1.utils.etag import add_etag
|
|
10
|
+
from howler.common.exceptions import (
|
|
11
|
+
AccessDeniedException,
|
|
12
|
+
AuthenticationException,
|
|
13
|
+
HowlerException,
|
|
14
|
+
HowlerValueError,
|
|
15
|
+
InvalidDataException,
|
|
16
|
+
)
|
|
17
|
+
from howler.common.loader import datastore
|
|
18
|
+
from howler.common.logging import get_logger
|
|
19
|
+
from howler.common.swagger import generate_swagger_docs
|
|
20
|
+
from howler.config import config
|
|
21
|
+
from howler.helper.oauth import fetch_groups
|
|
22
|
+
from howler.odm.models.user import User
|
|
23
|
+
from howler.security import api_login
|
|
24
|
+
from howler.security.utils import check_password_requirements, get_password_hash, get_password_requirement_message
|
|
25
|
+
|
|
26
|
+
SUB_API = "user"
|
|
27
|
+
user_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
28
|
+
user_api._doc = "Manage the different users of the system"
|
|
29
|
+
|
|
30
|
+
logger = get_logger(__file__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@generate_swagger_docs()
|
|
34
|
+
@user_api.route("/whoami", methods=["GET"])
|
|
35
|
+
@api_login(required_priv=["R"], enforce_quota=False)
|
|
36
|
+
def who_am_i(**kwargs):
|
|
37
|
+
"""Return the currently logged in user as well as the system configuration
|
|
38
|
+
|
|
39
|
+
Variables:
|
|
40
|
+
None
|
|
41
|
+
|
|
42
|
+
Arguments:
|
|
43
|
+
None
|
|
44
|
+
|
|
45
|
+
Result Example:
|
|
46
|
+
{
|
|
47
|
+
"avatar": "data:image/jpg...", # Avatar data block
|
|
48
|
+
"classification": "TLP:W", # Classification of the user
|
|
49
|
+
"configuration": { # Configuration block
|
|
50
|
+
"auth": { # Authentication Configuration
|
|
51
|
+
"allow_apikeys": True, # Are APIKeys allowed for the user
|
|
52
|
+
"allow_extended_apikeys": True, # Allow user to generate extended access API Keys
|
|
53
|
+
},
|
|
54
|
+
"system": { # System Configuration
|
|
55
|
+
"type": "production", # Type of deployment
|
|
56
|
+
"version": "4.1" # Howler version
|
|
57
|
+
},
|
|
58
|
+
"ui": { # UI Configuration
|
|
59
|
+
"apps": [], # List of apps shown in the apps switcher
|
|
60
|
+
"banner": None, # Banner displayed on the submit page
|
|
61
|
+
"banner_level": True, # Banner color (info, success, warning, error)
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"email": "basic.user@assemblyline.local", # Email of the user
|
|
65
|
+
"groups": ["USERS"], # Groups the user if member of
|
|
66
|
+
"is_active": True, # Is the user active
|
|
67
|
+
"name": "Basic user", # Name of the user
|
|
68
|
+
"type": ["user", "admin"], # Roles the user is member of
|
|
69
|
+
"username": "sgaron-cyber" # Username of the current user
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
"""
|
|
73
|
+
return ok(user_service.convert_user(kwargs["user"]))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@generate_swagger_docs()
|
|
77
|
+
@user_api.route("/<username>", methods=["POST"])
|
|
78
|
+
@api_login(required_type=["admin"])
|
|
79
|
+
def add_user_account(username, **_):
|
|
80
|
+
"""Add a user to the system
|
|
81
|
+
|
|
82
|
+
Variables:
|
|
83
|
+
username => Name of the user to add
|
|
84
|
+
|
|
85
|
+
Arguments:
|
|
86
|
+
None
|
|
87
|
+
|
|
88
|
+
Data Block:
|
|
89
|
+
{
|
|
90
|
+
"name": "Test user", # Name of the user
|
|
91
|
+
"is_active": true, # Is the user active?
|
|
92
|
+
"classification": "", # Max classification for user
|
|
93
|
+
"uname": "usertest", # Username
|
|
94
|
+
"type": ['user'], # List of all types the user is member of
|
|
95
|
+
"avatar": null, # Avatar of the user
|
|
96
|
+
"groups": ["TEST"] # Groups the user is member of
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
Result Example:
|
|
100
|
+
{
|
|
101
|
+
"success": true # Saving the user info succeded
|
|
102
|
+
}
|
|
103
|
+
"""
|
|
104
|
+
data = request.json
|
|
105
|
+
if not isinstance(data, dict):
|
|
106
|
+
return bad_request(err="Invalid data format")
|
|
107
|
+
|
|
108
|
+
if "{" in username or "}" in username:
|
|
109
|
+
return bad_request(err="You can't use '{}' in the username")
|
|
110
|
+
|
|
111
|
+
storage = datastore()
|
|
112
|
+
if storage.user.get_if_exists(username):
|
|
113
|
+
return bad_request(err="The username you are trying to add already exists.")
|
|
114
|
+
|
|
115
|
+
new_pass = data.pop("new_pass", None)
|
|
116
|
+
if new_pass:
|
|
117
|
+
password_requirements = config.auth.internal.password_requirements.model_dump()
|
|
118
|
+
if not check_password_requirements(new_pass, **password_requirements):
|
|
119
|
+
error_msg = get_password_requirement_message(**password_requirements)
|
|
120
|
+
return bad_request(err=error_msg)
|
|
121
|
+
data["password"] = get_password_hash(new_pass)
|
|
122
|
+
else:
|
|
123
|
+
data["password"] = data.get("password", "__NO_PASSWORD__") or "__NO_PASSWORD__"
|
|
124
|
+
|
|
125
|
+
# Data's username has to match the API call username
|
|
126
|
+
data["uname"] = username
|
|
127
|
+
if not data["name"]:
|
|
128
|
+
data["name"] = data["uname"]
|
|
129
|
+
|
|
130
|
+
# Add dynamic classification group
|
|
131
|
+
data["classification"] = user_service.get_dynamic_classification(
|
|
132
|
+
cast(str | None, data.get("classification", None)), data["email"]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Clear non user account data
|
|
136
|
+
avatar = data.pop("avatar", None)
|
|
137
|
+
|
|
138
|
+
if avatar is not None:
|
|
139
|
+
storage.user_avatar.save(username, avatar)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return ok({"success": storage.user.save(username, User(data))})
|
|
143
|
+
except ValueError as e:
|
|
144
|
+
return bad_request(err=str(e))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@generate_swagger_docs()
|
|
148
|
+
@user_api.route("/<username>", methods=["GET"])
|
|
149
|
+
@api_login(audit=False, required_priv=["R"])
|
|
150
|
+
@add_etag(getter=user_service.get_user, check_if_match=True)
|
|
151
|
+
def get_user_account(username: str, server_version: Optional[str] = None, **kwargs):
|
|
152
|
+
"""Load the user account information.
|
|
153
|
+
|
|
154
|
+
Variables:
|
|
155
|
+
username => Name of the user to get the account info
|
|
156
|
+
|
|
157
|
+
Arguments:
|
|
158
|
+
load_avatar => If exists, this will load the avatar as well
|
|
159
|
+
|
|
160
|
+
Result Example:
|
|
161
|
+
{
|
|
162
|
+
"name": "Test user", # Name of the user
|
|
163
|
+
"is_active": true, # Is the user active?
|
|
164
|
+
"classification": "", # Max classification for user
|
|
165
|
+
"uname": "usertest", # Username
|
|
166
|
+
"type": ['user'], # List of all types the user is member of
|
|
167
|
+
"avatar": null, # Avatar of the user
|
|
168
|
+
"groups": ["TEST"] # Groups the user is member of
|
|
169
|
+
}
|
|
170
|
+
"""
|
|
171
|
+
if username != kwargs["user"]["uname"] and "admin" not in kwargs["user"]["type"]:
|
|
172
|
+
return forbidden(err="You are not allow to view other users then yourself.")
|
|
173
|
+
|
|
174
|
+
user: Optional[User] = kwargs.get("cached_user")
|
|
175
|
+
if not user:
|
|
176
|
+
return not_found(err=f"User {username} does not exist")
|
|
177
|
+
|
|
178
|
+
user: dict[str, Any] = user.as_primitives()
|
|
179
|
+
user["apikeys"] = [(k, []) for k in user.get("apikeys", {}).keys()]
|
|
180
|
+
user["has_password"] = user.pop("password", "") != ""
|
|
181
|
+
user["roles"] = user.pop("type", [])
|
|
182
|
+
user["username"] = user["uname"]
|
|
183
|
+
|
|
184
|
+
if "load_avatar" in request.args:
|
|
185
|
+
user["avatar"] = datastore().user_avatar.get(username)
|
|
186
|
+
|
|
187
|
+
return ok(user), server_version
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@generate_swagger_docs()
|
|
191
|
+
@user_api.route("/<username>", methods=["DELETE"])
|
|
192
|
+
@api_login(required_type=["admin"])
|
|
193
|
+
def remove_user_account(username, **_):
|
|
194
|
+
"""Remove the account specified by the username.
|
|
195
|
+
|
|
196
|
+
Variables:
|
|
197
|
+
username => Name of the user to get the account info
|
|
198
|
+
|
|
199
|
+
Arguments:
|
|
200
|
+
None
|
|
201
|
+
|
|
202
|
+
Result Example:
|
|
203
|
+
{
|
|
204
|
+
"success": true # Was the remove successful?
|
|
205
|
+
}
|
|
206
|
+
"""
|
|
207
|
+
storage = datastore()
|
|
208
|
+
user_data = storage.user.get(username)
|
|
209
|
+
if user_data:
|
|
210
|
+
user_deleted = storage.user.delete(username)
|
|
211
|
+
|
|
212
|
+
if storage.user_avatar.exists(username):
|
|
213
|
+
avatar_deleted = storage.user_avatar.delete(username)
|
|
214
|
+
else:
|
|
215
|
+
avatar_deleted = True
|
|
216
|
+
|
|
217
|
+
if not user_deleted or not avatar_deleted:
|
|
218
|
+
logger.warning("Failed to delete user")
|
|
219
|
+
return internal_error(err="Failed to delete user or avatar. Contact your administrator.")
|
|
220
|
+
|
|
221
|
+
return no_content()
|
|
222
|
+
else:
|
|
223
|
+
return not_found(err=f"User {username} does not exist")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@generate_swagger_docs()
|
|
227
|
+
@user_api.route("/<username>", methods=["PUT"])
|
|
228
|
+
@api_login(required_type=["admin", "user"], enforce_quota=False)
|
|
229
|
+
def set_user_account(username: str, **kwargs): # noqa: C901
|
|
230
|
+
"""Save the user account information.
|
|
231
|
+
|
|
232
|
+
Variables:
|
|
233
|
+
username => Name of the user to get the account info
|
|
234
|
+
|
|
235
|
+
Arguments:
|
|
236
|
+
None
|
|
237
|
+
|
|
238
|
+
Data Block:
|
|
239
|
+
{
|
|
240
|
+
"name": "Test user", # Name of the user
|
|
241
|
+
"is_active": true, # Is the user active?
|
|
242
|
+
"classification": "", # Max classification for user
|
|
243
|
+
"uname": "usertest", # Username
|
|
244
|
+
"type": ['user'], # List of all types the user is member of
|
|
245
|
+
"avatar": null, # Avatar of the user
|
|
246
|
+
"groups": ["TEST"] # Groups the user is member of
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
Result Example:
|
|
250
|
+
{
|
|
251
|
+
"success": true # Saving the user info succeded
|
|
252
|
+
}
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
new_data = request.json
|
|
256
|
+
if not isinstance(new_data, dict):
|
|
257
|
+
return bad_request(err="Invalid data format")
|
|
258
|
+
|
|
259
|
+
storage = datastore()
|
|
260
|
+
if not (old_user := storage.user.get_if_exists(username, as_obj=False)):
|
|
261
|
+
return not_found(err=f"User {username} does not exist")
|
|
262
|
+
|
|
263
|
+
data = {**old_user, **new_data}
|
|
264
|
+
new_pass = data.pop("new_pass", None)
|
|
265
|
+
|
|
266
|
+
# Don't allow the overwriting of api keys
|
|
267
|
+
data["apikeys"] = old_user.get("apikeys", [])
|
|
268
|
+
|
|
269
|
+
# Don't allow overwriting of api quota unless you're an admin
|
|
270
|
+
if "admin" not in kwargs["user"]["type"]:
|
|
271
|
+
data["api_quota"] = old_user["api_quota"]
|
|
272
|
+
|
|
273
|
+
if not data["name"]:
|
|
274
|
+
return bad_request(err="Full name of the user cannot be empty")
|
|
275
|
+
|
|
276
|
+
if data["email"] != old_user["email"]:
|
|
277
|
+
return bad_request(err="Cannot update user's email")
|
|
278
|
+
|
|
279
|
+
if data["uname"] != old_user["uname"]:
|
|
280
|
+
return bad_request(err="Cannot update user's username")
|
|
281
|
+
|
|
282
|
+
password_requirements = config.auth.internal.password_requirements.model_dump()
|
|
283
|
+
if not new_pass:
|
|
284
|
+
data["password"] = old_user.get("password", "__NO_PASSWORD__") or "__NO_PASSWORD__"
|
|
285
|
+
elif not check_password_requirements(new_pass, **password_requirements):
|
|
286
|
+
error_msg = get_password_requirement_message(**password_requirements)
|
|
287
|
+
return bad_request(err=error_msg)
|
|
288
|
+
else:
|
|
289
|
+
data["password"] = get_password_hash(new_pass)
|
|
290
|
+
data.pop("new_pass_confirm", None)
|
|
291
|
+
|
|
292
|
+
# Apply dynamic classification
|
|
293
|
+
data["classification"] = user_service.get_dynamic_classification(data["classification"], data["email"])
|
|
294
|
+
|
|
295
|
+
ret_val = user_service.save_user_account(username, data, kwargs["user"])
|
|
296
|
+
return ok({"success": ret_val})
|
|
297
|
+
except AccessDeniedException as e:
|
|
298
|
+
return forbidden(err=str(e))
|
|
299
|
+
except (InvalidDataException, HowlerValueError) as e:
|
|
300
|
+
return bad_request(err=str(e))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
######################################################
|
|
304
|
+
# User's Avatar
|
|
305
|
+
######################################################
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@generate_swagger_docs()
|
|
309
|
+
@user_api.route("/avatar/<username>", methods=["GET"])
|
|
310
|
+
@api_login(audit=True, required_priv=["R"])
|
|
311
|
+
def get_user_avatar(username, **_):
|
|
312
|
+
"""Loads the user's avatar.
|
|
313
|
+
|
|
314
|
+
Variables:
|
|
315
|
+
username => Name of the user you want to get the avatar for
|
|
316
|
+
|
|
317
|
+
Arguments:
|
|
318
|
+
None
|
|
319
|
+
|
|
320
|
+
Result Example:
|
|
321
|
+
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD..."
|
|
322
|
+
"""
|
|
323
|
+
storage = datastore()
|
|
324
|
+
avatar: str = storage.user_avatar.get(username)
|
|
325
|
+
|
|
326
|
+
if avatar:
|
|
327
|
+
resp = ok(avatar)
|
|
328
|
+
resp.headers["Cache-Control"] = "private, max-age=3600"
|
|
329
|
+
resp.headers["ETag"] = sha256(avatar.encode("utf-8")).hexdigest()
|
|
330
|
+
return resp
|
|
331
|
+
else:
|
|
332
|
+
return no_content()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@generate_swagger_docs()
|
|
336
|
+
@user_api.route("/avatar/<username>", methods=["POST"])
|
|
337
|
+
@api_login(audit=True)
|
|
338
|
+
def set_user_avatar(username, **kwargs):
|
|
339
|
+
"""Sets the user's Avatar
|
|
340
|
+
|
|
341
|
+
Variables:
|
|
342
|
+
username => Name of the user you want to set the avatar for
|
|
343
|
+
|
|
344
|
+
Arguments:
|
|
345
|
+
None
|
|
346
|
+
|
|
347
|
+
Data Block:
|
|
348
|
+
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD..."
|
|
349
|
+
|
|
350
|
+
Result Example:
|
|
351
|
+
{
|
|
352
|
+
"success": true # Was saving the avatar successful ?
|
|
353
|
+
}
|
|
354
|
+
"""
|
|
355
|
+
if username != kwargs["user"]["uname"]:
|
|
356
|
+
return forbidden(err="Cannot save the avatar of another user.")
|
|
357
|
+
|
|
358
|
+
data = request.data
|
|
359
|
+
storage = datastore()
|
|
360
|
+
if data:
|
|
361
|
+
data: str = data.decode("utf-8")
|
|
362
|
+
if not isinstance(data, str) or not storage.user_avatar.save(username, data):
|
|
363
|
+
bad_request(
|
|
364
|
+
err="Data block should be a base64 encoded image that starts with 'data:image/<format>;base64,'"
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
storage.user_avatar.delete(username)
|
|
368
|
+
|
|
369
|
+
return ok()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@generate_swagger_docs()
|
|
373
|
+
@user_api.route("/groups", methods=["GET"])
|
|
374
|
+
@api_login(audit=False)
|
|
375
|
+
def get_user_groups(**kwargs):
|
|
376
|
+
"""Gets the user's groups from an oauth provider
|
|
377
|
+
|
|
378
|
+
Variables:
|
|
379
|
+
None
|
|
380
|
+
|
|
381
|
+
Arguments:
|
|
382
|
+
None
|
|
383
|
+
|
|
384
|
+
Result Example:
|
|
385
|
+
[
|
|
386
|
+
{
|
|
387
|
+
"name": "Group Name",
|
|
388
|
+
"id": "abc-123"
|
|
389
|
+
},
|
|
390
|
+
...
|
|
391
|
+
]
|
|
392
|
+
"""
|
|
393
|
+
auth_header = request.headers.get("Authorization", None)
|
|
394
|
+
|
|
395
|
+
if not auth_header:
|
|
396
|
+
raise AuthenticationException("No Authorization header present")
|
|
397
|
+
|
|
398
|
+
type, token = auth_header.split(" ")
|
|
399
|
+
|
|
400
|
+
group_data = None
|
|
401
|
+
if type == "Bearer" and "." in token:
|
|
402
|
+
try:
|
|
403
|
+
group_data = fetch_groups(token)
|
|
404
|
+
except HowlerException as e:
|
|
405
|
+
return internal_error(e.message)
|
|
406
|
+
|
|
407
|
+
if group_data is None:
|
|
408
|
+
group_data = []
|
|
409
|
+
for g in kwargs["user"].get("groups", []):
|
|
410
|
+
name = re.sub(r"^\w", lambda m: m.group(0).upper(), g)
|
|
411
|
+
name = re.sub(r"[-_]", " ", name)
|
|
412
|
+
name = re.sub(r" \w", lambda m: m.group(0).upper(), name)
|
|
413
|
+
|
|
414
|
+
group_data.append({"name": name, "id": g})
|
|
415
|
+
|
|
416
|
+
return ok(group_data)
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""ETag utility module for handling HTTP ETags in Flask responses.
|
|
2
|
+
|
|
3
|
+
ETags (Entity Tags) are HTTP headers used for web cache validation and conditional requests.
|
|
4
|
+
They help optimize performance by allowing clients to cache responses and only fetch
|
|
5
|
+
new data when the resource has actually changed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from flask import Response, request
|
|
12
|
+
|
|
13
|
+
from howler.api import not_modified
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def add_etag(getter, check_if_match=True):
|
|
17
|
+
"""Decorator to add ETag handling to a Flask response.
|
|
18
|
+
|
|
19
|
+
This decorator implements HTTP ETag functionality for API endpoints, enabling:
|
|
20
|
+
- Conditional requests using If-Match headers
|
|
21
|
+
- Cache validation to prevent unnecessary data transfers
|
|
22
|
+
- Version tracking for resources
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
getter: Function that retrieves the object and its version
|
|
26
|
+
check_if_match (bool): Whether to check If-Match headers for conditional requests
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Decorated function with ETag support
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def wrapper(f):
|
|
33
|
+
"""Inner wrapper function that applies ETag functionality to the decorated function."""
|
|
34
|
+
|
|
35
|
+
@functools.wraps(f)
|
|
36
|
+
def generate_etag(*args, **kwargs):
|
|
37
|
+
"""Generate and handle ETags for the HTTP response."""
|
|
38
|
+
# Retrieve the object and its version using the provided getter function
|
|
39
|
+
# The getter should return (object, version) tuple
|
|
40
|
+
obj, version = getter(
|
|
41
|
+
kwargs.get("id", kwargs.get("username", None)),
|
|
42
|
+
as_odm=True,
|
|
43
|
+
version=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Handle conditional requests with If-Match header
|
|
47
|
+
# If the client's version matches the current version and it's a GET request
|
|
48
|
+
# without metadata parameter, return 304 Not Modified to save bandwidth
|
|
49
|
+
if (
|
|
50
|
+
check_if_match
|
|
51
|
+
and "If-Match" in request.headers
|
|
52
|
+
and request.headers["If-Match"] == version
|
|
53
|
+
and request.method == "GET"
|
|
54
|
+
and "metadata" not in request.args
|
|
55
|
+
):
|
|
56
|
+
return not_modified()
|
|
57
|
+
|
|
58
|
+
# Extract the resource type from the API path and create a cache key
|
|
59
|
+
# e.g., "/api/v1/users/123" becomes "cached_users"
|
|
60
|
+
key = re.sub(r"^\/api\/v\d+\/(\w+)\/.+$", r"cached_\1", request.path)
|
|
61
|
+
kwargs[key] = obj
|
|
62
|
+
|
|
63
|
+
# Call the original function with the cached object and version
|
|
64
|
+
values = f(*args, server_version=version, **kwargs)
|
|
65
|
+
|
|
66
|
+
# Handle different return value formats from the decorated function
|
|
67
|
+
# If there is only one return, it's just the response
|
|
68
|
+
if isinstance(values, Response):
|
|
69
|
+
# Only add ETag header for successful responses (not 409 Conflict or 400 Bad Request)
|
|
70
|
+
if values.status_code != 409 and values.status_code != 400:
|
|
71
|
+
values.headers["ETag"] = version
|
|
72
|
+
return values
|
|
73
|
+
|
|
74
|
+
# If there are two returns, it's the response and the new version
|
|
75
|
+
# This happens when the function modifies the resource and returns an updated version
|
|
76
|
+
else:
|
|
77
|
+
if values[0].status_code != 409 and values[0].status_code != 400:
|
|
78
|
+
# Add the new ETag version to successful responses
|
|
79
|
+
values[0].headers["ETag"] = values[1]
|
|
80
|
+
return values[0]
|
|
81
|
+
|
|
82
|
+
return generate_etag
|
|
83
|
+
|
|
84
|
+
return wrapper
|