Skip to content

Commit 403aeca

Browse files
authored
Merge pull request #352 from TeskaLabs/feature/account-webui-migration-tweaks
Account web UI migration tweaks
2 parents df96cef + cfda436 commit 403aeca

File tree

17 files changed

+88
-50
lines changed

17 files changed

+88
-50
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## v24.06
44

55
### Pre-releases
6+
- `v24.06-alpha10`
67
- `v24.06-beta3`
78
- `v24.06-alpha9`
89
- `v24.06-alpha8`
@@ -20,13 +21,15 @@
2021
- Disable special characters in tenant ID (#349, `v24.06-alpha6`)
2122

2223
### Fix
24+
- Better TOTP error responses (#352, `v24.06-alpha10`)
2325
- Fix resource editability (#355, `v24.06-alpha9`)
2426
- Make FIDO MDS request non-blocking using TaskService (#354, `v24.06-alpha8`)
2527
- Improve error handling in FIDO MDS (#351, `v24.06-alpha5`)
2628
- Fix typo in last login endpoint path (#346, `v24.06-alpha4`)
2729
- Fix the initialization of NoTenantsError (#346, `v24.06-alpha2`)
2830

2931
### Features
32+
- External login provider label contains just the display name (#352, `v24.06-alpha10`)
3033
- ElasticSearch index and Kibana space authorization (#353, `v24.06-alpha7.2`)
3134
- Disable special characters in tenant ID (#349, `v24.06-alpha6`)
3235
- New paths for account management endpoints (#343, `v24.06-alpha3`)

seacatauth/authn/service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,13 @@ async def _prepare_login_descriptors(
279279
def get_login_factor(self, factor_type):
280280
return self.LoginFactors[factor_type]
281281

282+
async def get_eligible_factors(self, credentials_id: str):
283+
return [
284+
factor.Type
285+
for factor in self.LoginFactors.values()
286+
if await factor.is_eligible({"credentials_id": credentials_id})
287+
]
288+
282289
def create_login_factor(self, factor_config):
283290
self.LoginFactors[factor_config["type"]] = login_factor_builder(self, factor_config)
284291
return self.LoginFactors[factor_config["type"]]

seacatauth/authn/webauthn/handler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ async def list_credentials(self, request, *, credentials_id):
140140
"properties": {
141141
"name": {
142142
"type": "string",
143-
"pattern": "^[a-z][a-z0-9._-]{0,128}[a-z0-9]$"
143+
"minLength": 3,
144+
"maxLength": 128,
144145
},
145146
}
146147
})

seacatauth/cookie/service.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,17 @@ async def create_cookie_client_session(
191191
])
192192

193193
if "profile" in scope or "userinfo:authn" in scope or "userinfo:*" in scope:
194+
external_login_service = self.App.get_service("seacatauth.ExternalLoginService")
195+
available_factors = await self.AuthenticationService.get_eligible_factors(root_session.Credentials.Id)
196+
available_external_logins = {
197+
result["t"]: result["s"]
198+
for result in await external_login_service.list(root_session.Credentials.Id)
199+
}
194200
session_builders.append([
195201
(SessionAdapter.FN.Authentication.LoginDescriptor, root_session.Authentication.LoginDescriptor),
196202
(SessionAdapter.FN.Authentication.LoginFactors, root_session.Authentication.LoginFactors),
197-
(SessionAdapter.FN.Authentication.ExternalLoginOptions, root_session.Authentication.ExternalLoginOptions),
198-
(SessionAdapter.FN.Authentication.AvailableFactors, root_session.Authentication.AvailableFactors),
203+
(SessionAdapter.FN.Authentication.AvailableFactors, available_factors),
204+
(SessionAdapter.FN.Authentication.ExternalLoginOptions, available_external_logins),
199205
])
200206

201207
if root_session.TrackId is not None:

seacatauth/credentials/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
"description": "Email address",
1414
"anyOf": [
1515
{"type": "null"},
16+
{"type": "string", "const": ""},
1617
{"type": "string", "format": "email"},
1718
],
1819
},
1920
"phone": {
2021
"description": "Mobile number",
2122
"anyOf": [
2223
{"type": "null"},
24+
{"type": "string", "const": ""},
2325
{"type": "string", "pattern": r"^\+?[0-9 ]+$"},
2426
],
2527
},

seacatauth/exceptions.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,22 @@ def __init__(self, credentials_id, tenant, *args):
123123
super().__init__("Credentials do not have the tenant assigned.", *args)
124124

125125

126-
class TOTPNotActiveError(SeacatAuthError):
126+
class TOTPActivationError(SeacatAuthError):
127127
"""
128-
Credentials do not have any registered TOTP secret
128+
Failed to activate TOTP
129129
"""
130-
def __init__(self, credential_id: str):
131-
self.CredentialID: str = credential_id
132-
super().__init__("TOTP not active for credentials.")
130+
def __init__(self, message: str, credentials_id: str):
131+
self.CredentialsID: str = credentials_id
132+
super().__init__(message)
133+
134+
135+
class TOTPDeactivationError(SeacatAuthError):
136+
"""
137+
Failed to deactivate TOTP
138+
"""
139+
def __init__(self, message: str, credentials_id: str):
140+
self.CredentialsID: str = credentials_id
141+
super().__init__(message)
133142

134143

135144
class ClientResponseError(SeacatAuthError):

seacatauth/external_login/providers/appleid.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class AppleIDOAuth2Login(GenericOAuth2Login):
3535
"token_endpoint": "https://appleid.apple.com/auth/token",
3636
"jwks_uri": "https://appleid.apple.com/auth/keys",
3737
"scope": "name email",
38-
"label": "Sign in with Apple",
38+
"label": "AppleID",
3939
}
4040

4141
def __init__(self, external_login_svc, config_section_name, config=None):

seacatauth/external_login/providers/facebook.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class FacebookOAuth2Login(GenericOAuth2Login):
3333
"response_type": "code",
3434
"scope": "public_profile",
3535
"fields": "id,name,email",
36-
"label": "Sign in with Facebook",
36+
"label": "Facebook",
3737
}
3838

3939
def __init__(self, external_login_svc, config_section_name):

seacatauth/external_login/providers/github.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class GitHubOAuth2Login(GenericOAuth2Login):
2727
"userinfo_endpoint": "https://api.github.com/user",
2828
"user_emails_endpoint": "https://api.github.com/user/emails",
2929
"scope": "user:email", # Scope is not used
30-
"label": "Sign in with Github",
30+
"label": "Github",
3131
}
3232

3333
def __init__(self, external_login_svc, config_section_name):

seacatauth/external_login/providers/google.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ class GoogleOAuth2Login(GenericOAuth2Login):
1818
"authorization_endpoint": "https://accounts.google.com/o/oauth2/auth",
1919
"token_endpoint": "https://accounts.google.com/o/oauth2/token",
2020
"scope": "openid profile email",
21-
"label": "Sign in with Google",
21+
"label": "Google",
2222
}

seacatauth/external_login/providers/mojeid.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class MojeIDOAuth2Login(GenericOAuth2Login):
1818
"authorization_endpoint": "https://mojeid.cz/oidc/authorization/",
1919
"token_endpoint": "https://mojeid.cz/oidc/token/",
2020
"scope": "openid email phone",
21-
"label": "Sign in with MojeID",
21+
"label": "MojeID",
2222
}
2323

2424
# TODO: MojeID provides extensive settings for encryption algorithms and other features.

seacatauth/external_login/providers/office365.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class Office365OAuth2Login(GenericOAuth2Login):
2020
"token_endpoint": "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
2121
"tenant_id": "",
2222
"scope": "openid",
23-
"label": "Sign in with Office365",
23+
"label": "Office365",
2424
}
2525

2626
def __init__(self, external_login_svc, config_section_name):

seacatauth/feature/handler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def __init__(self, app, feture_svc):
2323

2424
web_app = app.WebContainer.WebApp
2525
web_app.router.add_get("/public/features", self.get_features)
26+
web_app.router.add_get("/account/features", self.get_features)
2627

2728
# Public endpoints
2829
web_app_public = app.PublicWebContainer.WebApp

seacatauth/openidconnect/service.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -256,14 +256,18 @@ async def create_oidc_session(
256256
]
257257

258258
if "profile" in scope or "userinfo:authn" in scope or "userinfo:*" in scope:
259+
authentication_service = self.App.get_service("seacatauth.AuthenticationService")
260+
external_login_service = self.App.get_service("seacatauth.ExternalLoginService")
261+
available_factors = await authentication_service.get_eligible_factors(root_session.Credentials.Id)
262+
available_external_logins = {
263+
result["t"]: result["s"]
264+
for result in await external_login_service.list(root_session.Credentials.Id)
265+
}
259266
session_builders.append([
260267
(SessionAdapter.FN.Authentication.LoginDescriptor, root_session.Authentication.LoginDescriptor),
261268
(SessionAdapter.FN.Authentication.LoginFactors, root_session.Authentication.LoginFactors),
262-
(SessionAdapter.FN.Authentication.AvailableFactors, root_session.Authentication.AvailableFactors),
263-
(
264-
SessionAdapter.FN.Authentication.ExternalLoginOptions,
265-
root_session.Authentication.ExternalLoginOptions
266-
),
269+
(SessionAdapter.FN.Authentication.AvailableFactors, available_factors),
270+
(SessionAdapter.FN.Authentication.ExternalLoginOptions, available_external_logins),
267271
])
268272

269273
if "batman" in scope:

seacatauth/otp/handler.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import asab.web.rest
55

66
from ..decorators import access_control
7-
from ..exceptions import TOTPNotActiveError
7+
from .. import exceptions
88

99
#
1010

@@ -28,18 +28,18 @@ def __init__(self, app, otp_svc):
2828

2929
web_app = app.WebContainer.WebApp
3030
web_app.router.add_get("/account/totp", self.prepare_totp_if_not_active)
31-
web_app.router.add_put("/account/set-totp", self.set_totp)
32-
web_app.router.add_put("/account/unset-totp", self.unset_totp)
31+
web_app.router.add_put("/account/set-totp", self.activate_totp)
32+
web_app.router.add_put("/account/unset-totp", self.deactivate_totp)
3333

3434
# Back-compat; To be removed in next major version
3535
# >>>
3636
web_app.router.add_get("/public/totp", self.prepare_totp_if_not_active)
37-
web_app.router.add_put("/public/set-totp", self.set_totp)
38-
web_app.router.add_put("/public/unset-totp", self.unset_totp)
37+
web_app.router.add_put("/public/set-totp", self.activate_totp)
38+
web_app.router.add_put("/public/unset-totp", self.deactivate_totp)
3939

4040
web_app_public.router.add_get("/public/totp", self.prepare_totp_if_not_active)
41-
web_app_public.router.add_put("/public/set-totp", self.set_totp)
42-
web_app_public.router.add_put("/public/unset-totp", self.unset_totp)
41+
web_app_public.router.add_put("/public/set-totp", self.activate_totp)
42+
web_app_public.router.add_put("/public/unset-totp", self.deactivate_totp)
4343
# <<<
4444

4545
@access_control()
@@ -71,27 +71,30 @@ async def prepare_totp_if_not_active(self, request, *, credentials_id):
7171
}
7272
})
7373
@access_control()
74-
async def set_totp(self, request, *, credentials_id, json_data):
74+
async def activate_totp(self, request, *, credentials_id, json_data):
7575
"""
7676
Activate TOTP for the current user
7777
7878
This requires that a TOTP secret is already prepared for the user.
7979
"""
8080
otp = json_data.get("otp")
81-
response = await self.OTPService.activate_prepared_totp(request.Session, credentials_id, otp)
82-
return asab.web.rest.json_response(request, response)
81+
try:
82+
await self.OTPService.activate_prepared_totp(request.Session, credentials_id, otp)
83+
except exceptions.TOTPActivationError:
84+
return asab.web.rest.json_response(request, {"result": "FAILED"}, status=400)
85+
return asab.web.rest.json_response(request, {"result": "OK"})
8386

8487

8588
@access_control()
86-
async def unset_totp(self, request, *, credentials_id):
89+
async def deactivate_totp(self, request, *, credentials_id):
8790
"""
8891
Deactivate TOTP for the current user
8992
9093
The user's TOTP secret is deleted.
9194
"""
9295
try:
9396
await self.OTPService.deactivate_totp(credentials_id)
94-
except TOTPNotActiveError:
97+
except exceptions.TOTPDeactivationError:
9598
return asab.web.rest.json_response(request, {"result": "FAILED"}, status=400)
9699

97100
return asab.web.rest.json_response(request, {"result": "OK"})

seacatauth/otp/service.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import asab.storage
99

1010
from typing import Optional
11-
from ..exceptions import TOTPNotActiveError
11+
from .. import exceptions
1212
from ..events import EventTypes
1313

1414
#
@@ -41,21 +41,23 @@ async def _on_housekeeping(self, event_name):
4141
await self._delete_expired_totp_secrets()
4242

4343

44-
async def deactivate_totp(self, credential_id: str):
44+
async def deactivate_totp(self, credentials_id: str):
4545
"""
4646
Delete active TOTP secret for requested credentials.
4747
"""
48-
if not await self.has_activated_totp(credential_id):
49-
raise TOTPNotActiveError(credential_id)
48+
if not await self.has_activated_totp(credentials_id):
49+
L.log(asab.LOG_NOTICE, "Cannot deactivate TOTP because it is already active.", struct_data={
50+
"cid": credentials_id})
51+
raise exceptions.TOTPDeactivationError("TOTP is not active.", credentials_id)
5052
try:
51-
await self.StorageService.delete(collection=self.TOTPCollection, obj_id=credential_id)
53+
await self.StorageService.delete(collection=self.TOTPCollection, obj_id=credentials_id)
5254
except KeyError:
5355
# TOTP are stored in the old self.PreparedTOTPCollection -> only for backward compatibility
5456
pass
5557

5658

57-
provider = self.CredentialsService.get_provider(credential_id)
58-
await provider.update(credential_id, {
59+
provider = self.CredentialsService.get_provider(credentials_id)
60+
await provider.update(credentials_id, {
5961
"__totp": None
6062
})
6163

@@ -86,29 +88,32 @@ async def activate_prepared_totp(self, session, credentials_id: str, request_otp
8688
Requires entering the generated OTP to succeed.
8789
"""
8890
if await self.has_activated_totp(credentials_id):
89-
return {"result": "FAILED"}
91+
L.log(asab.LOG_NOTICE, "Cannot activate TOTP because it is already active.", struct_data={
92+
"cid": credentials_id})
93+
raise exceptions.TOTPActivationError("TOTP is already active.", credentials_id)
9094

9195
try:
9296
secret = await self._get_prepared_totp_secret_by_session_id(session.SessionId)
9397
except KeyError:
94-
# TOTP secret has not been initialized or has expired
95-
return {"result": "FAILED"}
98+
L.log(asab.LOG_NOTICE, "Cannot activate TOTP because the secret is not ready or has expired.", struct_data={
99+
"cid": credentials_id})
100+
raise exceptions.TOTPActivationError("TOTP secret is not ready, or possibly has expired.", credentials_id)
96101

97102
totp = pyotp.TOTP(secret)
98103
if totp.verify(request_otp) is False:
99104
# TOTP secret does not match
100-
return {"result": "FAILED"}
105+
L.log(asab.LOG_NOTICE, "Cannot activate TOTP because the verification failed.", struct_data={
106+
"cid": credentials_id})
107+
raise exceptions.TOTPActivationError("TOTP verification failed.", credentials_id)
101108

102109
# Store secret in its own dedicated collection
103110
upsertor = self.StorageService.upsertor(collection=self.TOTPCollection, obj_id=credentials_id)
104111
upsertor.set("__totp", secret.encode("ascii"), encrypt=True)
105112
await upsertor.execute(event_type=EventTypes.TOTP_REGISTERED)
106-
L.log(asab.LOG_NOTICE, "TOTP secret registered.", struct_data={"cid": credentials_id})
113+
L.log(asab.LOG_NOTICE, "TOTP activated.", struct_data={"cid": credentials_id})
107114

108115
await self._delete_prepared_totp_secret(session.SessionId)
109116

110-
return {"result": "OK"}
111-
112117

113118
async def _create_totp_secret(self, session_id: str) -> str:
114119
"""
@@ -126,7 +131,7 @@ async def _create_totp_secret(self, session_id: str) -> str:
126131
expires: datetime.datetime = datetime.datetime.now(datetime.timezone.utc) + self.RegistrationTimeout
127132
upsertor.set("exp", expires)
128133

129-
secret: str = pyotp.random_base32().encode("ascii")
134+
secret = pyotp.random_base32().encode("ascii")
130135
upsertor.set("__s", secret, encrypt=True)
131136

132137
await upsertor.execute(event_type=EventTypes.TOTP_CREATED)

seacatauth/session/builders.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,7 @@ def authentication_session_builder(login_descriptor):
7070

7171

7272
async def available_factors_session_builder(authentication_service, credentials_id):
73-
factors = []
74-
for factor in authentication_service.LoginFactors.values():
75-
if await factor.is_eligible({"credentials_id": credentials_id}):
76-
factors.append(factor.Type)
73+
factors = await authentication_service.get_eligible_factors(credentials_id)
7774
return ((SessionAdapter.FN.Authentication.AvailableFactors, factors),)
7875

7976

0 commit comments

Comments
 (0)