Skip to content

Commit 84bac20

Browse files
authored
Explicitly allow creation of MC without auth (#249)
1 parent 2440b66 commit 84bac20

File tree

2 files changed

+103
-59
lines changed

2 files changed

+103
-59
lines changed

mergin/client.py

Lines changed: 42 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def __init__(
9494
proxy_config=None,
9595
):
9696
self.url = url if url is not None else MerginClient.default_url()
97-
self._auth_params = None
97+
self._auth_params = {}
9898
self._auth_session = None
9999
self._user_info = None
100100
self._server_type = None
@@ -192,36 +192,32 @@ def user_agent_info(self):
192192
system_version = platform.mac_ver()[0]
193193
return f"{self.client_version} ({platform.system()}/{system_version})"
194194

195-
def _check_token(f):
196-
"""Wrapper for creating/renewing authorization token."""
197-
198-
def wrapper(self, *args):
199-
if self._auth_params:
200-
if self._auth_session:
201-
# Refresh auth token if it expired or will expire very soon
202-
delta = self._auth_session["expire"] - datetime.now(timezone.utc)
203-
if delta.total_seconds() < 5:
204-
self.log.info("Token has expired - refreshing...")
205-
if self._auth_params.get("login", None) and self._auth_params.get("password", None):
206-
self.log.info("Token has expired - refreshing...")
207-
self.login(self._auth_params["login"], self._auth_params["password"])
208-
else:
209-
raise AuthTokenExpiredError("Token has expired - please re-login")
210-
else:
211-
# Create a new authorization token
212-
self.log.info(f"No token - login user: {self._auth_params['login']}")
213-
if self._auth_params.get("login", None) and self._auth_params.get("password", None):
214-
self.login(self._auth_params["login"], self._auth_params["password"])
215-
else:
216-
raise ClientError("Missing login or password")
217-
218-
return f(self, *args)
195+
def validate_auth(self):
196+
"""Validate that client has valid auth token or can be logged in."""
219197

220-
return wrapper
198+
if self._auth_session:
199+
# Refresh auth token if it expired or will expire very soon
200+
delta = self._auth_session["expire"] - datetime.now(timezone.utc)
201+
if delta.total_seconds() < 5:
202+
self.log.info("Token has expired - refreshing...")
203+
if self._auth_params.get("login", None) and self._auth_params.get("password", None):
204+
self.log.info("Token has expired - refreshing...")
205+
self.login(self._auth_params["login"], self._auth_params["password"])
206+
else:
207+
raise AuthTokenExpiredError("Token has expired - please re-login")
208+
else:
209+
# Create a new authorization token
210+
self.log.info(f"No token - login user: {self._auth_params.get('login', None)}")
211+
if self._auth_params.get("login", None) and self._auth_params.get("password", None):
212+
self.login(self._auth_params["login"], self._auth_params["password"])
213+
else:
214+
raise ClientError("Missing login or password")
221215

222-
@_check_token
223-
def _do_request(self, request):
216+
def _do_request(self, request, validate_auth=True):
224217
"""General server request method."""
218+
if validate_auth:
219+
self.validate_auth()
220+
225221
if self._auth_session:
226222
request.add_header("Authorization", self._auth_session["token"])
227223
request.add_header("User-Agent", self.user_agent_info())
@@ -263,31 +259,31 @@ def _do_request(self, request):
263259
# e.g. when DNS resolution fails (no internet connection?)
264260
raise ClientError("Error requesting " + request.full_url + ": " + str(e))
265261

266-
def get(self, path, data=None, headers={}):
262+
def get(self, path, data=None, headers={}, validate_auth=True):
267263
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
268264
if data:
269265
url += "?" + urllib.parse.urlencode(data)
270266
request = urllib.request.Request(url, headers=headers)
271-
return self._do_request(request)
267+
return self._do_request(request, validate_auth=validate_auth)
272268

273-
def post(self, path, data=None, headers={}):
269+
def post(self, path, data=None, headers={}, validate_auth=True):
274270
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
275271
if headers.get("Content-Type", None) == "application/json":
276272
data = json.dumps(data, cls=DateTimeEncoder).encode("utf-8")
277273
request = urllib.request.Request(url, data, headers, method="POST")
278-
return self._do_request(request)
274+
return self._do_request(request, validate_auth=validate_auth)
279275

280-
def patch(self, path, data=None, headers={}):
276+
def patch(self, path, data=None, headers={}, validate_auth=True):
281277
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
282278
if headers.get("Content-Type", None) == "application/json":
283279
data = json.dumps(data, cls=DateTimeEncoder).encode("utf-8")
284280
request = urllib.request.Request(url, data, headers, method="PATCH")
285-
return self._do_request(request)
281+
return self._do_request(request, validate_auth=validate_auth)
286282

287-
def delete(self, path):
283+
def delete(self, path, validate_auth=True):
288284
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
289285
request = urllib.request.Request(url, method="DELETE")
290-
return self._do_request(request)
286+
return self._do_request(request, validate_auth=validate_auth)
291287

292288
def login(self, login, password):
293289
"""
@@ -303,26 +299,16 @@ def login(self, login, password):
303299
self._auth_session = None
304300
self.log.info(f"Going to log in user {login}")
305301
try:
306-
self._auth_params = params
307-
url = urllib.parse.urljoin(self.url, urllib.parse.quote("/v1/auth/login"))
308-
data = json.dumps(self._auth_params, cls=DateTimeEncoder).encode("utf-8")
309-
request = urllib.request.Request(url, data, {"Content-Type": "application/json"}, method="POST")
310-
request.add_header("User-Agent", self.user_agent_info())
311-
resp = self.opener.open(request)
302+
resp = self.post(
303+
"/v1/auth/login", data=params, headers={"Content-Type": "application/json"}, validate_auth=False
304+
)
312305
data = json.load(resp)
313306
session = data["session"]
314-
except urllib.error.HTTPError as e:
315-
if e.headers.get("Content-Type", "") == "application/problem+json":
316-
info = json.load(e)
317-
self.log.info(f"Login problem: {info.get('detail')}")
318-
raise LoginError(info.get("detail"))
319-
self.log.info(f"Login problem: {e.read().decode('utf-8')}")
320-
raise LoginError(e.read().decode("utf-8"))
321-
except urllib.error.URLError as e:
322-
# e.g. when DNS resolution fails (no internet connection?)
323-
raise ClientError("failure reason: " + str(e.reason))
307+
except ClientError as e:
308+
self.log.info(f"Login problem: {e.detail}")
309+
raise LoginError(e.detail)
324310
self._auth_session = {
325-
"token": "Bearer %s" % session["token"],
311+
"token": f"Bearer {session['token']}",
326312
"expire": dateutil.parser.parse(session["expire"]),
327313
}
328314
self._user_info = {"username": data["username"]}
@@ -367,7 +353,7 @@ def server_type(self):
367353
"""
368354
if not self._server_type:
369355
try:
370-
resp = self.get("/config")
356+
resp = self.get("/config", validate_auth=False)
371357
config = json.load(resp)
372358
if config["server_type"] == "ce":
373359
self._server_type = ServerType.CE
@@ -389,7 +375,7 @@ def server_version(self):
389375
"""
390376
if self._server_version is None:
391377
try:
392-
resp = self.get("/config")
378+
resp = self.get("/config", validate_auth=False)
393379
config = json.load(resp)
394380
self._server_version = config["version"]
395381
except (ClientError, KeyError):
@@ -1386,7 +1372,7 @@ def remove_project_collaborator(self, project_id: str, user_id: int):
13861372

13871373
def server_config(self) -> dict:
13881374
"""Get server configuration as dictionary."""
1389-
response = self.get("/config")
1375+
response = self.get("/config", validate_auth=False)
13901376
return json.load(response)
13911377

13921378
def send_logs(

mergin/test/test_client.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import tempfile
66
import subprocess
77
import shutil
8-
from datetime import datetime, timedelta, date
8+
from datetime import datetime, timedelta, date, timezone
99
import pytest
1010
import pytz
1111
import sqlite3
@@ -14,6 +14,7 @@
1414
from .. import InvalidProject
1515
from ..client import (
1616
MerginClient,
17+
AuthTokenExpiredError,
1718
ClientError,
1819
MerginProject,
1920
LoginError,
@@ -2888,8 +2889,7 @@ def test_mc_without_login():
28882889
with pytest.raises(ClientError) as e:
28892890
mc.workspaces_list()
28902891

2891-
assert e.value.http_error == 401
2892-
assert e.value.detail == '"Authentication information is missing or invalid."\n'
2892+
assert e.value.detail == "Missing login or password"
28932893

28942894

28952895
def test_do_request_error_handling(mc: MerginClient):
@@ -2911,3 +2911,61 @@ def test_do_request_error_handling(mc: MerginClient):
29112911

29122912
assert e.value.http_error == 400
29132913
assert "Passwords must be at least 8 characters long." in e.value.detail
2914+
2915+
2916+
def test_validate_auth(mc: MerginClient):
2917+
"""Test validate authentication under different scenarios."""
2918+
2919+
# ----- Client without authentication -----
2920+
mc_not_auth = MerginClient(SERVER_URL)
2921+
2922+
with pytest.raises(ClientError) as e:
2923+
mc_not_auth.validate_auth()
2924+
2925+
assert e.value.detail == "Missing login or password"
2926+
2927+
# ----- Client with token -----
2928+
# create a client with valid auth token based on other MerginClient instance, but not with username/password
2929+
mc_auth_token = MerginClient(SERVER_URL, auth_token=mc._auth_session["token"])
2930+
2931+
# this should pass and not raise an error
2932+
mc_auth_token.validate_auth()
2933+
2934+
# manually set expire date to the past to simulate expired token
2935+
mc_auth_token._auth_session["expire"] = datetime.now(timezone.utc) - timedelta(days=1)
2936+
2937+
# check that this raises an error
2938+
with pytest.raises(AuthTokenExpiredError):
2939+
mc_auth_token.validate_auth()
2940+
2941+
# ----- Client with token and username/password -----
2942+
# create a client with valid auth token based on other MerginClient instance with username/password that allows relogin if the token is expired
2943+
mc_auth_token_login = MerginClient(
2944+
SERVER_URL, auth_token=mc._auth_session["token"], login=API_USER, password=USER_PWD
2945+
)
2946+
2947+
# this should pass and not raise an error
2948+
mc_auth_token_login.validate_auth()
2949+
2950+
# manually set expire date to the past to simulate expired token
2951+
mc_auth_token_login._auth_session["expire"] = datetime.now(timezone.utc) - timedelta(days=1)
2952+
2953+
# this should pass and not raise an error, as the client is able to re-login
2954+
mc_auth_token_login.validate_auth()
2955+
2956+
# ----- Client with token and username/WRONG password -----
2957+
# create a client with valid auth token based on other MerginClient instance with username and WRONG password
2958+
# that does NOT allow relogin if the token is expired
2959+
mc_auth_token_login_wrong_password = MerginClient(
2960+
SERVER_URL, auth_token=mc._auth_session["token"], login=API_USER, password="WRONG_PASSWORD"
2961+
)
2962+
2963+
# this should pass and not raise an error
2964+
mc_auth_token_login_wrong_password.validate_auth()
2965+
2966+
# manually set expire date to the past to simulate expired token
2967+
mc_auth_token_login_wrong_password._auth_session["expire"] = datetime.now(timezone.utc) - timedelta(days=1)
2968+
2969+
# this should pass and not raise an error, as the client is able to re-login
2970+
with pytest.raises(LoginError):
2971+
mc_auth_token_login_wrong_password.validate_auth()

0 commit comments

Comments
 (0)