dspace-api-toolkit 0.1.1__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.
- dspace_api/__init__.py +75 -0
- dspace_api/_base.py +407 -0
- dspace_api/_containers.py +217 -0
- dspace_api/_version.py +4 -0
- dspace_api/auth.py +467 -0
- dspace_api/authorizations.py +486 -0
- dspace_api/bundles.py +1193 -0
- dspace_api/collections.py +1069 -0
- dspace_api/communities.py +363 -0
- dspace_api/config.py +254 -0
- dspace_api/discovery.py +920 -0
- dspace_api/entity_types.py +127 -0
- dspace_api/epersons.py +647 -0
- dspace_api/exceptions.py +110 -0
- dspace_api/groups.py +599 -0
- dspace_api/items.py +549 -0
- dspace_api/metadata.py +742 -0
- dspace_api/permissions.py +1052 -0
- dspace_api/projection.py +292 -0
- dspace_api/relationships.py +694 -0
- dspace_api/session.py +538 -0
- dspace_api/site.py +98 -0
- dspace_api/utils.py +519 -0
- dspace_api/versions.py +292 -0
- dspace_api/vocabulary.py +365 -0
- dspace_api/workflow.py +893 -0
- dspace_api/workspaces.py +1404 -0
- dspace_api_toolkit-0.1.1.dist-info/METADATA +211 -0
- dspace_api_toolkit-0.1.1.dist-info/RECORD +32 -0
- dspace_api_toolkit-0.1.1.dist-info/WHEEL +5 -0
- dspace_api_toolkit-0.1.1.dist-info/licenses/LICENSE +28 -0
- dspace_api_toolkit-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Features shared by communities and collections ("containers"): logo,
|
|
4
|
+
parent community, metadata PATCH and the *Authorized search methods.
|
|
5
|
+
|
|
6
|
+
GET /core/{communities|collections}/{uuid}/logo 204 if none
|
|
7
|
+
POST /core/{communities|collections}/{uuid}/logo multipart, only when there is no logo
|
|
8
|
+
DELETE /core/bitstreams/{logo uuid} removes the logo (both types)
|
|
9
|
+
GET /core/{communities|collections}/{uuid}/parentCommunity 204 for top-level communities
|
|
10
|
+
PATCH /core/{communities|collections}/{uuid} metadata (MetadataPatch)
|
|
11
|
+
GET /core/{communities|collections}/search/findAdminAuthorized?query=
|
|
12
|
+
GET /core/{communities|collections}/search/findEditAuthorized?query=
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import logging
|
|
17
|
+
import mimetypes
|
|
18
|
+
from typing import Any, Dict, Iterable, List, Optional, Union
|
|
19
|
+
|
|
20
|
+
from .metadata import MetadataPatch, as_operations
|
|
21
|
+
from .projection import EmbedArg
|
|
22
|
+
|
|
23
|
+
# Logging setup for the module
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# API paths for DSpace containers and bitstreams
|
|
27
|
+
BITSTREAMS = "/core/bitstreams"
|
|
28
|
+
COMMUNITIES = "/core/communities"
|
|
29
|
+
|
|
30
|
+
# Timeout for logo upload requests (in seconds)
|
|
31
|
+
LOGO_TIMEOUT = 120
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ContainerMixin:
|
|
35
|
+
"""Mixin providing shared functionality for community and collection managers.
|
|
36
|
+
|
|
37
|
+
Includes methods for handling logos, parent communities, and metadata PATCH operations.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Container-specific constants
|
|
41
|
+
_CONTAINER_PATH = "" # "/core/communities" or "/core/collections"
|
|
42
|
+
_CONTAINER_KEY = "" # HAL key of search results: "communities" / "collections"
|
|
43
|
+
|
|
44
|
+
def _container_url(self, uuid: str, suffix: str = "") -> str:
|
|
45
|
+
"""Construct the URL for a container resource, optionally with a suffix.
|
|
46
|
+
|
|
47
|
+
Parameters:
|
|
48
|
+
uuid: The UUID of the container.
|
|
49
|
+
suffix: Optional suffix to append to the URL.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The full URL for the container resource as a string.
|
|
53
|
+
"""
|
|
54
|
+
return self._url(self._CONTAINER_PATH, f"/{uuid}{suffix}")
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
# Logo
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def get_logo(self, uuid: str) -> Optional[Dict[str, Any]]:
|
|
61
|
+
"""The logo bitstream, or None.
|
|
62
|
+
|
|
63
|
+
Parameters:
|
|
64
|
+
uuid: The UUID of the container.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The logo bitstream as a dictionary, or None if there is no logo.
|
|
68
|
+
"""
|
|
69
|
+
return self._get_optional(self._container_url(uuid, "/logo"), f"Get logo of {uuid}")
|
|
70
|
+
|
|
71
|
+
def set_logo(self, uuid: str, source: Any, name: Optional[str] = None,
|
|
72
|
+
mime_type: Optional[str] = None) -> Dict[str, Any]:
|
|
73
|
+
"""Upload a logo for the container, replacing any existing one.
|
|
74
|
+
|
|
75
|
+
Parameters:
|
|
76
|
+
uuid: The UUID of the container.
|
|
77
|
+
source: The source of the logo (file path, bytes, or file-like object).
|
|
78
|
+
name: Optional name of the file.
|
|
79
|
+
mime_type: Optional MIME type of the file.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The new logo bitstream as a dictionary.
|
|
83
|
+
"""
|
|
84
|
+
from .bundles import BundleManager
|
|
85
|
+
name, content = BundleManager._read_source(source, name)
|
|
86
|
+
mime_type = mime_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
87
|
+
if self.delete_logo(uuid):
|
|
88
|
+
logger.info("Replaced the existing logo of %s", uuid)
|
|
89
|
+
logo = self._request("POST", self._container_url(uuid, "/logo"), f"Upload logo of {uuid}",
|
|
90
|
+
expected=(200, 201), headers=self._headers(content_type=None),
|
|
91
|
+
files={"file": (name, content, mime_type)}, timeout=LOGO_TIMEOUT).json()
|
|
92
|
+
remote_md5 = ((logo.get("checkSum") or {}).get("value") or "").lower()
|
|
93
|
+
if remote_md5 and remote_md5 != hashlib.md5(content).hexdigest():
|
|
94
|
+
logger.warning("Logo of %s: server MD5 %s differs from the local file", uuid, remote_md5)
|
|
95
|
+
return logo
|
|
96
|
+
|
|
97
|
+
def delete_logo(self, uuid: str) -> bool:
|
|
98
|
+
"""Remove the logo of the container.
|
|
99
|
+
|
|
100
|
+
Parameters:
|
|
101
|
+
uuid: The UUID of the container.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True if a logo was deleted, False if there was none.
|
|
105
|
+
"""
|
|
106
|
+
logo = self.get_logo(uuid)
|
|
107
|
+
if not logo:
|
|
108
|
+
return False
|
|
109
|
+
self._request("DELETE", self._url(BITSTREAMS, f"/{logo['uuid']}"), f"Delete logo of {uuid}",
|
|
110
|
+
expected=(204,))
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# Hierarchy
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def get_parent_community(self, uuid: str, embed: EmbedArg = None) -> Optional[Dict[str, Any]]:
|
|
118
|
+
"""Retrieve the (first) parent community of the container.
|
|
119
|
+
|
|
120
|
+
Parameters:
|
|
121
|
+
uuid: The UUID of the container.
|
|
122
|
+
embed: Optional HAL embed parameter.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The parent community as a dictionary, or None for top-level communities.
|
|
126
|
+
"""
|
|
127
|
+
return self._get_optional(self._container_url(uuid, "/parentCommunity"), f"Get parent community of {uuid}",
|
|
128
|
+
embed=embed)
|
|
129
|
+
|
|
130
|
+
def get_ancestors(self, uuid: str, max_depth: int = 50) -> List[Dict[str, Any]]:
|
|
131
|
+
"""Retrieve the ancestor communities of the container, from the top-level one down to the direct parent.
|
|
132
|
+
|
|
133
|
+
Parameters:
|
|
134
|
+
uuid: The UUID of the container.
|
|
135
|
+
max_depth: Maximum number of ancestor levels to retrieve.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
A list of ancestor communities as dictionaries, ordered from top-level to direct parent.
|
|
139
|
+
"""
|
|
140
|
+
trail: List[Dict[str, Any]] = []
|
|
141
|
+
parent = self._get_optional(self._container_url(uuid, "/parentCommunity"), f"Get parent of {uuid}")
|
|
142
|
+
while parent and len(trail) < max_depth:
|
|
143
|
+
trail.insert(0, parent)
|
|
144
|
+
parent = self._get_optional(self._url(COMMUNITIES, f"/{parent['uuid']}/parentCommunity"),
|
|
145
|
+
f"Get parent of {parent['uuid']}")
|
|
146
|
+
return trail
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# Metadata
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def patch_metadata(self, uuid: str, patch: Union[MetadataPatch, Iterable[Dict[str, Any]]]) -> Dict[str, Any]:
|
|
153
|
+
"""Apply a MetadataPatch (or raw operations) to the container's metadata.
|
|
154
|
+
|
|
155
|
+
Parameters:
|
|
156
|
+
uuid: The UUID of the container.
|
|
157
|
+
patch: A MetadataPatch object or an iterable of raw JSON Patch operations.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The updated container as a dictionary.
|
|
161
|
+
"""
|
|
162
|
+
ops = as_operations(patch)
|
|
163
|
+
if not ops:
|
|
164
|
+
raise ValueError("Empty metadata patch")
|
|
165
|
+
return self._request("PATCH", self._container_url(uuid), f"Patch metadata of {uuid}",
|
|
166
|
+
headers=self._headers(), json=ops).json()
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Authorized searches
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def _authorized_search(self, method: str, query: Optional[str], extra=(), max_results: Optional[int] = None,
|
|
173
|
+
embed: EmbedArg = None) -> List[Dict[str, Any]]:
|
|
174
|
+
"""Perform an authorized search for containers based on the given method and query.
|
|
175
|
+
|
|
176
|
+
Parameters:
|
|
177
|
+
method: The search method to use (e.g., 'findAdminAuthorized').
|
|
178
|
+
query: Optional search query string.
|
|
179
|
+
extra: Additional query parameters as a list of key-value pairs.
|
|
180
|
+
max_results: Maximum number of results to return.
|
|
181
|
+
embed: Optional HAL embed parameter.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
A list of containers matching the search criteria.
|
|
185
|
+
"""
|
|
186
|
+
params = list(extra) + ([("query", query)] if query else [])
|
|
187
|
+
url = self._url(self._CONTAINER_PATH, f"/search/{method}")
|
|
188
|
+
return list(self._iter_embedded(url, self._CONTAINER_KEY, method, params, max_results=max_results,
|
|
189
|
+
embed=embed))
|
|
190
|
+
|
|
191
|
+
def find_admin_authorized(self, query: Optional[str] = None, max_results: Optional[int] = None,
|
|
192
|
+
embed: EmbedArg = None) -> List[Dict[str, Any]]:
|
|
193
|
+
"""Retrieve containers that the current user administers.
|
|
194
|
+
|
|
195
|
+
Parameters:
|
|
196
|
+
query: Optional search query string.
|
|
197
|
+
max_results: Maximum number of results to return.
|
|
198
|
+
embed: Optional HAL embed parameter.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
A list of containers administered by the current user.
|
|
202
|
+
"""
|
|
203
|
+
return self._authorized_search("findAdminAuthorized", query, max_results=max_results, embed=embed)
|
|
204
|
+
|
|
205
|
+
def find_edit_authorized(self, query: Optional[str] = None, max_results: Optional[int] = None,
|
|
206
|
+
embed: EmbedArg = None) -> List[Dict[str, Any]]:
|
|
207
|
+
"""Retrieve containers that the current user may edit (WRITE).
|
|
208
|
+
|
|
209
|
+
Parameters:
|
|
210
|
+
query: Optional search query string.
|
|
211
|
+
max_results: Maximum number of results to return.
|
|
212
|
+
embed: Optional HAL embed parameter.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
A list of containers the current user may edit.
|
|
216
|
+
"""
|
|
217
|
+
return self._authorized_search("findEditAuthorized", query, max_results=max_results, embed=embed)
|
dspace_api/_version.py
ADDED
dspace_api/auth.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Authentication and session management for DSpace API
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import datetime
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from contextlib import nullcontext
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from .config import DSpaceConfig, Endpoints
|
|
17
|
+
from .exceptions import AuthenticationError, SessionError, handle_response_error
|
|
18
|
+
from .session import DSpaceSession
|
|
19
|
+
|
|
20
|
+
# Logging setup for the module
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def jwt_expiry(authorization):
|
|
25
|
+
"""
|
|
26
|
+
Expiry (unix time) of a 'Bearer <jwt>' header value, or None if it is
|
|
27
|
+
not a JWT or carries no 'exp'. The signature is not verified - this is
|
|
28
|
+
only used to decide when to refresh our own token.
|
|
29
|
+
|
|
30
|
+
Parameters:
|
|
31
|
+
authorization: The 'Authorization' header value (e.g., 'Bearer <jwt>').
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The expiry time as a float (unix time) or None if not available.
|
|
35
|
+
"""
|
|
36
|
+
if not authorization:
|
|
37
|
+
return None
|
|
38
|
+
token = authorization.split(" ", 1)[1] if " " in authorization else authorization
|
|
39
|
+
parts = token.split(".")
|
|
40
|
+
if len(parts) != 3:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
44
|
+
exp = json.loads(base64.urlsafe_b64decode(payload)).get("exp")
|
|
45
|
+
return float(exp) if exp is not None else None
|
|
46
|
+
except (ValueError, TypeError, AttributeError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DSpaceAuth:
|
|
51
|
+
"""Handles authentication and session management for DSpace API"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, config: DSpaceConfig):
|
|
54
|
+
"""Initialize the DSpaceAuth instance with the given configuration.
|
|
55
|
+
|
|
56
|
+
Parameters:
|
|
57
|
+
config: The DSpaceConfig instance containing API configuration.
|
|
58
|
+
"""
|
|
59
|
+
self.config = config
|
|
60
|
+
self.session = self._new_session()
|
|
61
|
+
self._authenticated = False
|
|
62
|
+
# Kept in memory only, to log in again when the token expires on long runs
|
|
63
|
+
self._username = None
|
|
64
|
+
self._password = None
|
|
65
|
+
self._token_expires_at = None
|
|
66
|
+
self._reauth_lock = threading.RLock()
|
|
67
|
+
|
|
68
|
+
def _new_session(self):
|
|
69
|
+
"""Create a new DSpace session.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
DSpaceSession: A new session instance.
|
|
73
|
+
"""
|
|
74
|
+
return DSpaceSession(self)
|
|
75
|
+
|
|
76
|
+
def _internal(self):
|
|
77
|
+
"""Scope for the auth's own requests (no refresh / re-login inside them).
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Context manager for internal requests.
|
|
81
|
+
"""
|
|
82
|
+
if isinstance(self.session, DSpaceSession):
|
|
83
|
+
return self.session.internal()
|
|
84
|
+
return nullcontext()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def token_expires_at(self):
|
|
88
|
+
"""Unix time at which the current login token expires (None if unknown).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
float: The expiry time as a float (unix time) or None if not available.
|
|
92
|
+
"""
|
|
93
|
+
return self._token_expires_at
|
|
94
|
+
|
|
95
|
+
def _set_authorization(self, value):
|
|
96
|
+
"""Set the authorization header for the session and update the token expiry.
|
|
97
|
+
|
|
98
|
+
Parameters:
|
|
99
|
+
value: The 'Authorization' header value (e.g., 'Bearer <jwt>').
|
|
100
|
+
"""
|
|
101
|
+
self.session.headers.update({"Authorization": value})
|
|
102
|
+
self._token_expires_at = jwt_expiry(value)
|
|
103
|
+
if self._token_expires_at:
|
|
104
|
+
logger.debug("Login token valid until %s",
|
|
105
|
+
datetime.datetime.fromtimestamp(self._token_expires_at).isoformat())
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def is_authenticated(self):
|
|
109
|
+
"""Check if the session is currently authenticated.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
bool: True if authenticated, False otherwise.
|
|
113
|
+
"""
|
|
114
|
+
return self._authenticated
|
|
115
|
+
|
|
116
|
+
def create_session(self, username=None, password=None):
|
|
117
|
+
"""
|
|
118
|
+
Create and authenticate a session
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
username: DSpace username
|
|
122
|
+
password: DSpace password
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
requests.Session: Authenticated session
|
|
126
|
+
"""
|
|
127
|
+
if username and password:
|
|
128
|
+
self.login(username, password)
|
|
129
|
+
else:
|
|
130
|
+
self._setup_csrf_token()
|
|
131
|
+
|
|
132
|
+
return self.session
|
|
133
|
+
|
|
134
|
+
def check_status(self):
|
|
135
|
+
"""
|
|
136
|
+
Check the authentication status of the current session
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
bool: True if authenticated, False otherwise
|
|
140
|
+
"""
|
|
141
|
+
logger.info("Checking authentication status")
|
|
142
|
+
|
|
143
|
+
status_url = self.config.dspace_url + Endpoints.AUTHN_STATUS
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
with self._internal():
|
|
147
|
+
response = self.session.get(status_url, timeout=self.config.timeout)
|
|
148
|
+
self._update_token(response)
|
|
149
|
+
|
|
150
|
+
if response.status_code == 200:
|
|
151
|
+
data = response.json()
|
|
152
|
+
authenticated = data.get("authenticated", False)
|
|
153
|
+
|
|
154
|
+
if authenticated:
|
|
155
|
+
logger.info("User is authenticated")
|
|
156
|
+
self._authenticated = True
|
|
157
|
+
return True
|
|
158
|
+
else:
|
|
159
|
+
logger.info("User is not authenticated")
|
|
160
|
+
self._setup_csrf_token()
|
|
161
|
+
return False
|
|
162
|
+
else:
|
|
163
|
+
handle_response_error(response, "Status check")
|
|
164
|
+
|
|
165
|
+
except requests.exceptions.RequestException as e:
|
|
166
|
+
raise SessionError(f"Failed to check session status: {e}")
|
|
167
|
+
|
|
168
|
+
def _setup_csrf_token(self):
|
|
169
|
+
"""
|
|
170
|
+
Setup CSRF token for the session
|
|
171
|
+
|
|
172
|
+
Parameters:
|
|
173
|
+
None
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
None
|
|
177
|
+
"""
|
|
178
|
+
csrf_url = self.config.dspace_url + Endpoints.CSRF
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
with self._internal():
|
|
182
|
+
response = self.session.get(csrf_url, timeout=self.config.timeout)
|
|
183
|
+
logger.debug("CSRF status code: %s", response.status_code)
|
|
184
|
+
|
|
185
|
+
if "DSPACE-XSRF-TOKEN" in response.headers:
|
|
186
|
+
token = response.headers["DSPACE-XSRF-TOKEN"]
|
|
187
|
+
logger.debug("Setting XSRF token: %s...", token[:6])
|
|
188
|
+
self.session.headers.update({"X-XSRF-Token": token})
|
|
189
|
+
self.session.cookies.update({"X-XSRF-Token": token})
|
|
190
|
+
else:
|
|
191
|
+
logger.warning("No XSRF token found in response headers")
|
|
192
|
+
|
|
193
|
+
except requests.exceptions.RequestException as e:
|
|
194
|
+
raise SessionError(f"Failed to setup CSRF token: {e}")
|
|
195
|
+
|
|
196
|
+
def login(self, username, password):
|
|
197
|
+
"""
|
|
198
|
+
Authenticate with DSpace
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
username: DSpace username
|
|
202
|
+
password: DSpace password
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
AuthenticationError: If login fails
|
|
206
|
+
"""
|
|
207
|
+
if not username or not password:
|
|
208
|
+
raise AuthenticationError("Username and password are required")
|
|
209
|
+
|
|
210
|
+
# First check status to get CSRF token if needed
|
|
211
|
+
self.check_status()
|
|
212
|
+
|
|
213
|
+
login_url = self.config.dspace_url + Endpoints.AUTHN_LOGIN
|
|
214
|
+
login_data = {"user": username, "password": password}
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
with self._internal():
|
|
218
|
+
response = self.session.post(
|
|
219
|
+
login_url,
|
|
220
|
+
data=login_data,
|
|
221
|
+
timeout=self.config.timeout
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
self._update_token(response)
|
|
225
|
+
|
|
226
|
+
logger.info("Login attempt status: %s", response.status_code)
|
|
227
|
+
logger.info("Login time: %s", datetime.datetime.now())
|
|
228
|
+
|
|
229
|
+
if response.status_code == 200:
|
|
230
|
+
logger.info("Login successful")
|
|
231
|
+
self._authenticated = True
|
|
232
|
+
if getattr(self.config, "reauthenticate", True):
|
|
233
|
+
self._username, self._password = username, password
|
|
234
|
+
|
|
235
|
+
# Update session with authorization header if present
|
|
236
|
+
if "Authorization" in response.headers:
|
|
237
|
+
self._set_authorization(response.headers.get("Authorization"))
|
|
238
|
+
else:
|
|
239
|
+
self._handle_login_error(response)
|
|
240
|
+
|
|
241
|
+
except requests.exceptions.RequestException as e:
|
|
242
|
+
raise AuthenticationError(f"Login request failed: {e}")
|
|
243
|
+
|
|
244
|
+
def _handle_login_error(self, response):
|
|
245
|
+
"""Handle login error responses
|
|
246
|
+
|
|
247
|
+
Parameters:
|
|
248
|
+
response: The HTTP response object from the login attempt.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
AuthenticationError: If the login failed due to invalid credentials or forbidden access.
|
|
252
|
+
"""
|
|
253
|
+
if response.status_code == 401:
|
|
254
|
+
raise AuthenticationError("Invalid credentials")
|
|
255
|
+
elif response.status_code == 403:
|
|
256
|
+
try:
|
|
257
|
+
error_data = response.json()
|
|
258
|
+
error_msg = error_data.get('message', 'Access forbidden')
|
|
259
|
+
raise AuthenticationError(f"Forbidden: {error_msg}")
|
|
260
|
+
except json.JSONDecodeError:
|
|
261
|
+
raise AuthenticationError("Access forbidden")
|
|
262
|
+
else:
|
|
263
|
+
handle_response_error(response, "Login")
|
|
264
|
+
|
|
265
|
+
def _update_token(self, response):
|
|
266
|
+
"""Update XSRF token if present in response headers
|
|
267
|
+
|
|
268
|
+
Parameters:
|
|
269
|
+
response: The HTTP response object from which to extract the XSRF token.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
None
|
|
273
|
+
"""
|
|
274
|
+
if "DSPACE-XSRF-TOKEN" in response.headers:
|
|
275
|
+
token = response.headers["DSPACE-XSRF-TOKEN"]
|
|
276
|
+
logger.debug("Updating XSRF token: %s...", token[:6])
|
|
277
|
+
self.session.headers.update({"X-XSRF-Token": token})
|
|
278
|
+
self.session.cookies.update({"X-XSRF-Token": token})
|
|
279
|
+
|
|
280
|
+
def logout(self):
|
|
281
|
+
"""
|
|
282
|
+
Logout and clean up the session
|
|
283
|
+
|
|
284
|
+
Parameters:
|
|
285
|
+
None
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
None
|
|
289
|
+
"""
|
|
290
|
+
if not self._authenticated:
|
|
291
|
+
logger.info("Session not authenticated, skipping logout")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
logout_url = self.config.dspace_url + Endpoints.AUTHN_LOGOUT
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
with self._internal():
|
|
298
|
+
response = self.session.post(logout_url, timeout=self.config.timeout)
|
|
299
|
+
|
|
300
|
+
logger.info("Logout status: %s", response.status_code)
|
|
301
|
+
logger.info("Logout time: %s", datetime.datetime.now())
|
|
302
|
+
|
|
303
|
+
if response.status_code == 204:
|
|
304
|
+
logger.info("Logout successful")
|
|
305
|
+
self._cleanup_session()
|
|
306
|
+
else:
|
|
307
|
+
logger.warning("Logout returned status %s", response.status_code)
|
|
308
|
+
self._cleanup_session() # Clean up anyway
|
|
309
|
+
|
|
310
|
+
except requests.exceptions.RequestException as e:
|
|
311
|
+
logger.error("Logout request failed: %s", e)
|
|
312
|
+
self._cleanup_session() # Clean up anyway
|
|
313
|
+
|
|
314
|
+
def _cleanup_session(self):
|
|
315
|
+
"""Clean up session headers and close session
|
|
316
|
+
|
|
317
|
+
Parameters:
|
|
318
|
+
None
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
None
|
|
322
|
+
"""
|
|
323
|
+
# Remove auth-related headers
|
|
324
|
+
headers_to_remove = ["Authorization", "X-XSRF-Token"]
|
|
325
|
+
for header in headers_to_remove:
|
|
326
|
+
self.session.headers.pop(header, None)
|
|
327
|
+
|
|
328
|
+
# Remove auth-related cookies
|
|
329
|
+
cookies_to_remove = ["X-XSRF-Token"]
|
|
330
|
+
for cookie in cookies_to_remove:
|
|
331
|
+
self.session.cookies.pop(cookie, None)
|
|
332
|
+
|
|
333
|
+
self._authenticated = False
|
|
334
|
+
self._username = self._password = None
|
|
335
|
+
self._token_expires_at = None
|
|
336
|
+
self.session.close()
|
|
337
|
+
|
|
338
|
+
# Create new session for potential reuse
|
|
339
|
+
self.session = self._new_session()
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
# Keeping the login alive on long runs
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def can_reauthenticate(self):
|
|
346
|
+
"""Check if the session can reauthenticate.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
bool: True if reauthentication is possible, False otherwise.
|
|
350
|
+
"""
|
|
351
|
+
return bool(self._username and self._password and getattr(self.config, "reauthenticate", True))
|
|
352
|
+
|
|
353
|
+
def ensure_fresh_token(self):
|
|
354
|
+
"""Refresh the token if it expires within config.token_refresh_margin seconds.
|
|
355
|
+
|
|
356
|
+
Parameters:
|
|
357
|
+
None
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
None
|
|
361
|
+
"""
|
|
362
|
+
expires = self._token_expires_at
|
|
363
|
+
if not self._authenticated or expires is None:
|
|
364
|
+
return
|
|
365
|
+
margin = getattr(self.config, "token_refresh_margin", 60)
|
|
366
|
+
if time.time() < expires - margin:
|
|
367
|
+
return
|
|
368
|
+
with self._reauth_lock:
|
|
369
|
+
# another thread may have refreshed while we waited for the lock
|
|
370
|
+
if self._token_expires_at is not None and time.time() < self._token_expires_at - margin:
|
|
371
|
+
return
|
|
372
|
+
self.refresh_token()
|
|
373
|
+
|
|
374
|
+
def refresh_token(self):
|
|
375
|
+
"""
|
|
376
|
+
Get a new token. DSpace issues one for a POST to /authn/login that
|
|
377
|
+
carries a still-valid token; if that fails, log in again.
|
|
378
|
+
|
|
379
|
+
Parameters:
|
|
380
|
+
None
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
bool: True if the token was successfully refreshed or reauthenticated, False otherwise.
|
|
384
|
+
"""
|
|
385
|
+
login_url = self.config.dspace_url + Endpoints.AUTHN_LOGIN
|
|
386
|
+
try:
|
|
387
|
+
with self._internal():
|
|
388
|
+
response = self.session.post(login_url, timeout=self.config.timeout)
|
|
389
|
+
self._update_token(response)
|
|
390
|
+
if response.status_code == 200 and "Authorization" in response.headers:
|
|
391
|
+
self._set_authorization(response.headers["Authorization"])
|
|
392
|
+
logger.info("Login token refreshed")
|
|
393
|
+
return True
|
|
394
|
+
logger.info("Token refresh returned %s - logging in again", response.status_code)
|
|
395
|
+
except requests.exceptions.RequestException as e:
|
|
396
|
+
logger.warning("Token refresh failed (%s) - logging in again", e)
|
|
397
|
+
return self.reauthenticate()
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
# DSpace-CRIS end-user agreement
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
# DSpace-CRIS end-user agreement field in the eperson metadata
|
|
404
|
+
AGREEMENT_FIELD = "dspace.agreements.end-user"
|
|
405
|
+
|
|
406
|
+
def accept_user_agreement(self):
|
|
407
|
+
"""
|
|
408
|
+
Accept the DSpace-CRIS end-user agreement for the logged-in user and
|
|
409
|
+
get a new token (the server reads the flag from a token claim).
|
|
410
|
+
Returns True if the agreement is accepted afterwards.
|
|
411
|
+
|
|
412
|
+
Parameters:
|
|
413
|
+
None
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
bool: True if the agreement is accepted successfully, False otherwise.
|
|
417
|
+
"""
|
|
418
|
+
with self._reauth_lock:
|
|
419
|
+
try:
|
|
420
|
+
with self._internal():
|
|
421
|
+
status = self.session.get(self.config.dspace_url + Endpoints.AUTHN_STATUS,
|
|
422
|
+
params={"embed": "eperson"}, timeout=self.config.timeout)
|
|
423
|
+
self._update_token(status)
|
|
424
|
+
person = ((status.json() if status.status_code == 200 else {}).get("_embedded") or {}
|
|
425
|
+
).get("eperson") or {}
|
|
426
|
+
if not person.get("uuid"):
|
|
427
|
+
logger.warning("Cannot accept the user agreement: no logged-in eperson")
|
|
428
|
+
return False
|
|
429
|
+
values = (person.get("metadata") or {}).get(self.AGREEMENT_FIELD) or []
|
|
430
|
+
if not any(str(v.get("value")).lower() == "true" for v in values):
|
|
431
|
+
operation = {"op": "replace" if values else "add",
|
|
432
|
+
"path": f"/metadata/{self.AGREEMENT_FIELD}",
|
|
433
|
+
"value": [{"value": "true"}]}
|
|
434
|
+
patch = self.session.patch(
|
|
435
|
+
f"{self.config.dspace_url}{Endpoints.EPERSONS}/{person['uuid']}",
|
|
436
|
+
json=[operation], headers=self.config.headers, timeout=self.config.timeout)
|
|
437
|
+
self._update_token(patch)
|
|
438
|
+
if patch.status_code != 200:
|
|
439
|
+
logger.warning("Accepting the user agreement failed: HTTP %s %s",
|
|
440
|
+
patch.status_code, patch.text[:200])
|
|
441
|
+
return False
|
|
442
|
+
logger.info("End-user agreement accepted for %s", person.get("email") or person["uuid"])
|
|
443
|
+
except (requests.exceptions.RequestException, ValueError) as e:
|
|
444
|
+
logger.warning("Accepting the user agreement failed: %s", e)
|
|
445
|
+
return False
|
|
446
|
+
# new token -> new userAgreementAccepted claim
|
|
447
|
+
return self.refresh_token()
|
|
448
|
+
|
|
449
|
+
def reauthenticate(self):
|
|
450
|
+
"""Log in again with the stored credentials (used after expiry or a 401).
|
|
451
|
+
|
|
452
|
+
Parameters:
|
|
453
|
+
None
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
bool: True if reauthentication is successful, False otherwise.
|
|
457
|
+
"""
|
|
458
|
+
if not self.can_reauthenticate():
|
|
459
|
+
raise AuthenticationError("Session expired and no stored credentials to log in again")
|
|
460
|
+
with self._reauth_lock:
|
|
461
|
+
# Drop the dead token, otherwise /authn/status itself may answer 401
|
|
462
|
+
self.session.headers.pop("Authorization", None)
|
|
463
|
+
self._token_expires_at = None
|
|
464
|
+
self._authenticated = False
|
|
465
|
+
self.login(self._username, self._password)
|
|
466
|
+
logger.info("Logged in again")
|
|
467
|
+
return True
|