Skip to content

Commit 5d41bf9

Browse files
authored
feat: Login flow v2 (#255)
Changes proposed in this pull request: * Implement Login flow v2 for standard Nextcloud clients. * Allow user to initialize standard Nextcloud clients without providing credentials so that the user can log in using Login flow v2.
1 parent ddce6bf commit 5d41bf9

File tree

7 files changed

+222
-5
lines changed

7 files changed

+222
-5
lines changed

docs/reference/LoginFlowV2.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.. py:currentmodule:: nc_py_api.loginflow_v2
2+
3+
LoginFlow V2
4+
============
5+
6+
Login flow v2 is an authorization process for the standard Nextcloud client that allows each client to have their own set of credentials.
7+
8+
.. autoclass:: _LoginFlowV2API
9+
:inherited-members:
10+
:members:
11+
12+
.. autoclass:: Credentials
13+
:inherited-members:
14+
:members:
15+
16+
.. autoclass:: LoginFlow
17+
:inherited-members:
18+
:members:

docs/reference/Session.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@ Internal
2323
.. autoclass:: NcSessionApp
2424
:members:
2525
:inherited-members:
26+
27+
.. autoclass:: NcSession
28+
:members:

docs/reference/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ Reference
1616
ActivityApp
1717
Notes
1818
Session
19+
LoginFlowV2

nc_py_api/_session.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ class Config(BasicConfig):
103103

104104
def __init__(self, **kwargs):
105105
super().__init__(**kwargs)
106-
self.auth = (self._get_config_value("nc_auth_user", **kwargs), self._get_config_value("nc_auth_pass", **kwargs))
106+
nc_auth_user = self._get_config_value("nc_auth_user", raise_not_found=False, **kwargs)
107+
nc_auth_pass = self._get_config_value("nc_auth_pass", raise_not_found=False, **kwargs)
108+
if nc_auth_user and nc_auth_pass:
109+
self.auth = (nc_auth_user, nc_auth_pass)
107110

108111

109112
@dataclass

nc_py_api/loginflow_v2.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Login flow v2 API wrapper."""
2+
3+
import asyncio
4+
import json
5+
import time
6+
from dataclasses import dataclass
7+
8+
import httpx
9+
10+
from ._exceptions import check_error
11+
from ._session import AsyncNcSession, NcSession
12+
13+
MAX_TIMEOUT = 60 * 20
14+
15+
16+
@dataclass
17+
class LoginFlow:
18+
"""The Nextcloud Login flow v2 initialization response representation."""
19+
20+
def __init__(self, raw_data: dict) -> None:
21+
self.raw_data = raw_data
22+
23+
@property
24+
def login(self) -> str:
25+
"""The URL for user authorization.
26+
27+
Should be opened by the user in the default browser to authorize in Nextcloud.
28+
"""
29+
return self.raw_data["login"]
30+
31+
@property
32+
def token(self) -> str:
33+
"""Token for a polling for confirmation of user authorization."""
34+
return self.raw_data["poll"]["token"]
35+
36+
@property
37+
def endpoint(self) -> str:
38+
"""Endpoint for polling."""
39+
return self.raw_data["poll"]["endpoint"]
40+
41+
def __repr__(self) -> str:
42+
return f"<{self.__class__.__name__} login_url={self.login}>"
43+
44+
45+
@dataclass
46+
class Credentials:
47+
"""The Nextcloud Login flow v2 response with app credentials representation."""
48+
49+
def __init__(self, raw_data: dict) -> None:
50+
self.raw_data = raw_data
51+
52+
@property
53+
def server(self) -> str:
54+
"""The address of Nextcloud to connect to.
55+
56+
The server may specify a protocol (http or https). If no protocol is specified https will be used.
57+
"""
58+
return self.raw_data["server"]
59+
60+
@property
61+
def login_name(self) -> str:
62+
"""The username for authenticating with Nextcloud."""
63+
return self.raw_data["loginName"]
64+
65+
@property
66+
def app_password(self) -> str:
67+
"""The application password generated for authenticating with Nextcloud."""
68+
return self.raw_data["appPassword"]
69+
70+
def __repr__(self) -> str:
71+
return f"<{self.__class__.__name__} login={self.login_name} app_password={self.app_password}>"
72+
73+
74+
class _LoginFlowV2API:
75+
"""Class implementing Nextcloud Login flow v2."""
76+
77+
_ep_init: str = "/index.php/login/v2"
78+
_ep_poll: str = "/index.php/login/v2/poll"
79+
80+
def __init__(self, session: NcSession) -> None:
81+
self._session = session
82+
83+
def init(self, user_agent: str = "nc_py_api") -> LoginFlow:
84+
"""Init a Login flow v2.
85+
86+
:param user_agent: Application name. Application password will be associated with this name.
87+
"""
88+
r = self._session.adapter.post(self._ep_init, headers={"user-agent": user_agent})
89+
return LoginFlow(_res_to_json(r))
90+
91+
def poll(self, token: str, timeout: int = MAX_TIMEOUT, step: int = 1, overwrite_auth: bool = True) -> Credentials:
92+
"""Poll the Login flow v2 credentials.
93+
94+
:param token: Token for a polling for confirmation of user authorization.
95+
:param timeout: Maximum time to wait for polling in seconds, defaults to MAX_TIMEOUT.
96+
:param step: Interval for polling in seconds, defaults to 1.
97+
:param overwrite_auth: If True current session will be overwritten with new credentials, defaults to True.
98+
:raises ValueError: If timeout more than 20 minutes.
99+
"""
100+
if timeout > MAX_TIMEOUT:
101+
msg = "Timeout can't be more than 20 minutes."
102+
raise ValueError(msg)
103+
for _ in range(timeout // step):
104+
r = self._session.adapter.post(self._ep_poll, data={"token": token})
105+
if r.status_code == 200:
106+
break
107+
time.sleep(step)
108+
r_model = Credentials(_res_to_json(r))
109+
if overwrite_auth:
110+
self._session.cfg.auth = (r_model.login_name, r_model.app_password)
111+
self._session.init_adapter(restart=True)
112+
self._session.init_adapter_dav(restart=True)
113+
return r_model
114+
115+
116+
class _AsyncLoginFlowV2API:
117+
"""Class implementing Async Nextcloud Login flow v2."""
118+
119+
_ep_init: str = "/index.php/login/v2"
120+
_ep_poll: str = "/index.php/login/v2/poll"
121+
122+
def __init__(self, session: AsyncNcSession) -> None:
123+
self._session = session
124+
125+
async def init(self, user_agent: str = "nc_py_api") -> LoginFlow:
126+
"""Init a Login flow v2.
127+
128+
:param user_agent: Application name. Application password will be associated with this name.
129+
"""
130+
r = await self._session.adapter.post(self._ep_init, headers={"user-agent": user_agent})
131+
return LoginFlow(_res_to_json(r))
132+
133+
async def poll(
134+
self, token: str, timeout: int = MAX_TIMEOUT, step: int = 1, overwrite_auth: bool = True
135+
) -> Credentials:
136+
"""Poll the Login flow v2 credentials.
137+
138+
:param token: Token for a polling for confirmation of user authorization.
139+
:param timeout: Maximum time to wait for polling in seconds, defaults to MAX_TIMEOUT.
140+
:param step: Interval for polling in seconds, defaults to 1.
141+
:param overwrite_auth: If True current session will be overwritten with new credentials, defaults to True.
142+
:raises ValueError: If timeout more than 20 minutes.
143+
"""
144+
if timeout > MAX_TIMEOUT:
145+
raise ValueError("Timeout can't be more than 20 minutes.")
146+
for _ in range(timeout // step):
147+
r = await self._session.adapter.post(self._ep_poll, data={"token": token})
148+
if r.status_code == 200:
149+
break
150+
await asyncio.sleep(step)
151+
r_model = Credentials(_res_to_json(r))
152+
if overwrite_auth:
153+
self._session.cfg.auth = (r_model.login_name, r_model.app_password)
154+
self._session.init_adapter(restart=True)
155+
self._session.init_adapter_dav(restart=True)
156+
return r_model
157+
158+
159+
def _res_to_json(response: httpx.Response) -> dict:
160+
check_error(response)
161+
return json.loads(response.text)

nc_py_api/nextcloud.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from .ex_app.providers.providers import AsyncProvidersApi, ProvidersApi
3636
from .ex_app.ui.ui import AsyncUiApi, UiApi
3737
from .files.files import AsyncFilesAPI, FilesAPI
38+
from .loginflow_v2 import _AsyncLoginFlowV2API, _LoginFlowV2API
3839
from .notes import _AsyncNotesAPI, _NotesAPI
3940
from .notifications import _AsyncNotificationsAPI, _NotificationsAPI
4041
from .user_status import _AsyncUserStatusAPI, _UserStatusAPI
@@ -246,15 +247,18 @@ class Nextcloud(_NextcloudBasic):
246247
"""
247248

248249
_session: NcSession
250+
loginflow_v2: _LoginFlowV2API
251+
"""Nextcloud Login flow v2."""
249252

250253
def __init__(self, **kwargs):
251254
"""If the parameters are not specified, they will be taken from the environment.
252255
253256
:param nextcloud_url: url of the nextcloud instance.
254-
:param nc_auth_user: login username.
255-
:param nc_auth_pass: password or app-password for the username.
257+
:param nc_auth_user: login username. Optional.
258+
:param nc_auth_pass: password or app-password for the username. Optional.
256259
"""
257260
self._session = NcSession(**kwargs)
261+
self.loginflow_v2 = _LoginFlowV2API(self._session)
258262
super().__init__(self._session)
259263

260264
@property
@@ -270,15 +274,18 @@ class AsyncNextcloud(_AsyncNextcloudBasic):
270274
"""
271275

272276
_session: AsyncNcSession
277+
loginflow_v2: _AsyncLoginFlowV2API
278+
"""Nextcloud Login flow v2."""
273279

274280
def __init__(self, **kwargs):
275281
"""If the parameters are not specified, they will be taken from the environment.
276282
277283
:param nextcloud_url: url of the nextcloud instance.
278-
:param nc_auth_user: login username.
279-
:param nc_auth_pass: password or app-password for the username.
284+
:param nc_auth_user: login username. Optional.
285+
:param nc_auth_pass: password or app-password for the username. Optional.
280286
"""
281287
self._session = AsyncNcSession(**kwargs)
288+
self.loginflow_v2 = _AsyncLoginFlowV2API(self._session)
282289
super().__init__(self._session)
283290

284291
@property
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import pytest
2+
3+
from nc_py_api import NextcloudException
4+
5+
6+
def test_init_poll(nc_client):
7+
lf = nc_client.loginflow_v2.init()
8+
assert isinstance(lf.endpoint, str)
9+
assert isinstance(lf.login, str)
10+
assert isinstance(lf.token, str)
11+
with pytest.raises(NextcloudException) as exc_info:
12+
nc_client.loginflow_v2.poll(lf.token, 1)
13+
assert exc_info.value.status_code == 404
14+
15+
16+
@pytest.mark.asyncio(scope="session")
17+
async def test_init_poll_async(anc_client):
18+
lf = await anc_client.loginflow_v2.init()
19+
assert isinstance(lf.endpoint, str)
20+
assert isinstance(lf.login, str)
21+
assert isinstance(lf.token, str)
22+
with pytest.raises(NextcloudException) as exc_info:
23+
await anc_client.loginflow_v2.poll(lf.token, 1)
24+
assert exc_info.value.status_code == 404

0 commit comments

Comments
 (0)