Skip to content

Commit

Permalink
chore: initial working support for FIDO2 workflow
Browse files Browse the repository at this point in the history
The code structure still requires some major changes to make it elegant.
Some performance improvements are also needed.
  • Loading branch information
joamag committed May 21, 2024
1 parent c6e5d38 commit 9b5b33f
Show file tree
Hide file tree
Showing 7 changed files with 254 additions and 98 deletions.
36 changes: 36 additions & 0 deletions src/appier_extras/parts/admin/models/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ class Account(base.Base, authenticable.Authenticable):

confirmation_token = appier.field(safe=True, private=True, meta="secret")

fido2_enabled = appier.field(type=bool, description="FIDO2 Enabled")

otp_enabled = appier.field(type=bool, description="OTP Enabled")

otp_secret = appier.field(
Expand Down Expand Up @@ -932,6 +934,32 @@ def remove_role_s(self, name):
self.roles_l.remove(_role)
self.save()

@appier.operation(
name="Add Credential",
parameters=(
("Credential ID", "credential_id", str),
("Credential Data", "credential_data", str),
),
)
def add_credential_s(self, credential_id, credential_data):
from . import credential

_credential = credential.Credential(
credential_id=credential_id, credential_data=credential_data, account=self
)
_credential.save()
self.fido2_enabled = True
self.save()

@appier.operation(
name="Remove Credential", parameters=(("Credential ID", "credential_id", str),)
)
def remove_credential_s(self, credential_id):
from . import credential

_credential = credential.Credential.get(credential_id=credential_id)
_credential.delete()

@appier.operation(name="Fix Roles", level=2)
def fix_children_s(self):
self.roles = [role for role in self.roles if role and hasattr(role, "tokens_a")]
Expand Down Expand Up @@ -1047,6 +1075,8 @@ def two_factor_enabled(self):

@property
def two_factor_method(self):
if self.fido2_enabled:
return "fido2"
if self.otp_enabled:
return "otp"
return None
Expand All @@ -1060,6 +1090,12 @@ def otp_uri(self):
self.otp_secret,
)

@property
def credentials_data(self):
from . import credential

return credential.Credential.credentials_data_account(self)

def _set_session(self, unset=True, safes=[], method="set", two_factor=True):
cls = self.__class__
if unset:
Expand Down
154 changes: 86 additions & 68 deletions src/appier_extras/parts/admin/models/credential.py
Original file line number Diff line number Diff line change
@@ -1,68 +1,86 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-

# Hive Appier Framework
# Copyright (c) 2008-2024 Hive Solutions Lda.
#
# This file is part of Hive Appier Framework.
#
# Hive Appier Framework is free software: you can redistribute it and/or modify
# it under the terms of the Apache License as published by the Apache
# Foundation, either version 2.0 of the License, or (at your option) any
# later version.
#
# Hive Appier Framework is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# Apache License for more details.
#
# You should have received a copy of the Apache License along with
# Hive Appier Framework. If not, see <http://www.apache.org/licenses/>.

__author__ = "João Magalhães <[email protected]>"
""" The author(s) of the module """

__copyright__ = "Copyright (c) 2008-2024 Hive Solutions Lda."
""" The copyright for the module """

__license__ = "Apache License, Version 2.0"
""" The license for the module """

import appier

from appier_extras.parts.admin.models import base


class Credential(base.Base):

credential_id = appier.field(
index=True, immutable=True, description="Credential ID"
)

credential_data = appier.field(immutable=True)

account = appier.field(type=appier.reference("Account", name="id"), immutable=True)

@classmethod
def validate(cls):
return super(Credential, cls).validate() + [
appier.not_null("credential_id"),
appier.not_empty("credential_id"),
appier.not_null("credential_data"),
appier.not_empty("credential_data"),
]

@classmethod
def list_names(cls):
return ["credential_id", "description", "account"]

def post_create(self):
base.Base.post_create(self)
account = self.account.reload()
if not self in account.credentials:
account.credentials.append(self)
account.save()

@classmethod
def get_by_credential_id(cls, credential_id, *args, **kwargs):
return cls.get(credential_id=credential_id, eager=("account",), *args, **kwargs)
#!/usr/bin/python
# -*- coding: utf-8 -*-

# Hive Appier Framework
# Copyright (c) 2008-2024 Hive Solutions Lda.
#
# This file is part of Hive Appier Framework.
#
# Hive Appier Framework is free software: you can redistribute it and/or modify
# it under the terms of the Apache License as published by the Apache
# Foundation, either version 2.0 of the License, or (at your option) any
# later version.
#
# Hive Appier Framework is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# Apache License for more details.
#
# You should have received a copy of the Apache License along with
# Hive Appier Framework. If not, see <http://www.apache.org/licenses/>.

__author__ = "João Magalhães <[email protected]>"
""" The author(s) of the module """

__copyright__ = "Copyright (c) 2008-2024 Hive Solutions Lda."
""" The copyright for the module """

__license__ = "Apache License, Version 2.0"
""" The license for the module """

import base64

import appier

from appier_extras.parts.admin.models import base


class Credential(base.Base):

credential_id = appier.field(
index=True, immutable=True, description="Credential ID"
)

credential_data = appier.field(immutable=True)

account = appier.field(type=appier.reference("Account", name="id"), immutable=True)

@classmethod
def validate(cls):
return super(Credential, cls).validate() + [
appier.not_null("credential_id"),
appier.not_empty("credential_id"),
appier.not_null("credential_data"),
appier.not_empty("credential_data"),
]

@classmethod
def list_names(cls):
return ["credential_id", "description", "account"]

def post_create(self):
base.Base.post_create(self)
account = self.account.reload()
if not self in account.credentials:
account.credentials.append(self)
account.save()

def post_delete(self):
base.Base.post_create(self)
account = self.account.reload()
account.save()

@classmethod
def get_by_credential_id(cls, credential_id, *args, **kwargs):
return cls.get(credential_id=credential_id, eager=("account",), *args, **kwargs)

@classmethod
def find_by_account(cls, account, *args, **kwargs):
return cls.find(account=account.id, *args, **kwargs)

@classmethod
def credentials_data_account(cls, account, *args, **kwargs):
credentials = cls.find_by_account(account=account, *args, **kwargs)
return [
base64.b64decode(credential.credential_data) for credential in credentials
]
84 changes: 66 additions & 18 deletions src/appier_extras/parts/admin/part.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ def load(self):
self.owner.two_factor_route_admin = "admin.two_factor"
self.owner.otp_route = "admin.otp"
self.owner.otp_route_admin = "admin.otp"
self.owner.fido2_route = "admin.fido2"
self.owner.fido2_route_admin = "admin.fido2"
self.owner.admin_account = self.account_c
self.owner.admin_role = self.role_c
self.owner.admin_login_redirect = "admin.index"
Expand Down Expand Up @@ -681,6 +683,10 @@ def two_factor(self):
next = self.field("next")
error = self.field("error")
two_factor_method = self.session["2fa.method"]
if two_factor_method == "fido2":
return self.redirect(
self.url_for(self.owner.fido2_route_admin, next=next, error=error)
)
if two_factor_method == "otp":
return self.redirect(
self.url_for(self.owner.otp_route_admin, next=next, error=error)
Expand Down Expand Up @@ -740,27 +746,67 @@ def fido2(self):
next = self.field("next")
error = self.field("error")

# @TODO: think if this belongs here
username = self.session["2fa.username"]
account = self.account_c.get(username=username)

if not account.enabled:
raise appier.OperationalError(message="Account is not enabled", code=403)

import fido2.webauthn

credentials_data = [
fido2.webauthn.AttestedCredentialData(credential_data)
for credential_data in account.credentials_data
]

# @TODO this must be moved into the Account model
fido2_server = self._get_fido2_server()
auth_data, state = fido2_server.authenticate_begin(credentials_data)
state_json = json.dumps(state)
self.session["state"] = state_json

return self.template("fido2.html.tpl", next=next, error=error)
auth_data_d = dict(auth_data)
auth_data_json = json.dumps(auth_data_d, cls=utils.BytesEncoder)

return self.template(
"fido2.html.tpl", next=next, error=error, auth_data=auth_data_json
)

def fido2_login(self):
next = self.field("next")
response = self.field("response")
state_json = self.session["state"]

response_data = json.loads(response)
state = json.loads(state_json)

username = self.session["2fa.username"]
account = self.account_c.get(username=username)

fido2_server = appier.import_pip("fido2_server")
if not account.enabled:
raise appier.OperationalError(message="Account is not enabled", code=403)

# @TODO: this must be moved to the account model
registration_data, state = fido2_server.register_begin(
dict(
id=username.encode("utf-8"),
name=username,
displayName=username,
),
user_verification="discouraged",
)
import fido2.webauthn

self.session["2fa.username"] = username
credentials_data = [
fido2.webauthn.AttestedCredentialData(credential_data)
for credential_data in account.credentials_data
]

# @TODO this must be moved into the Account model
fido2_server = self._get_fido2_server()

fido2_server.authenticate_complete(state, credentials_data, response_data)

# updates the current session with the proper
# values to correctly authenticate the user
account.touch_login_s()
account._set_account()

# redirects the current operation to the next URL or in
# alternative to the root index of the administration
return self.redirect(next or self.url_for(self.owner.admin_login_redirect))

def fido2_register(self):
next = self.field("next")
Expand All @@ -780,12 +826,11 @@ def fido2_register(self):
user_verification="discouraged",
)
state_json = json.dumps(state)
self.session["state"] = state_json

registration_data_d = dict(registration_data)
registration_data_json = json.dumps(registration_data_d, cls=utils.BytesEncoder)

self.session["state"] = state_json

return self.template(
"fido2_register.html.tpl",
next=next,
Expand All @@ -807,14 +852,17 @@ def fido2_register_do(self):

# converts the credential data into a Base64 encoded string
# the strings is structured in the WebAuthn standard format
credential_id_b64 = base64.b64encode(
auth_data.credential_data.credential_id
).decode("utf-8")
credential_data_b64 = base64.b64encode(auth_data.credential_data).decode(
"utf-8"
)

# prints the information that should be save in the
# account for proper FIDO2 authentication
print(credential_data_b64)
print(auth_data.counter)
# adds the new credential to the account effectively enabling
# FIDO2 based authentication for the account
account = self.account_c.from_session(meta=True)
account.add_credential_s(credential_id_b64, credential_data_b64)

# redirects the current operation to the next URL or in
# alternative to the root index of the administration
Expand Down
6 changes: 5 additions & 1 deletion src/appier_extras/parts/admin/static/css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,10 @@ body.mobile-s .pages > .page.other {
margin: 0px auto 0px auto;
}

.fido2 {
.fido2-register {
display: none;
}

.fido2-auth {
display: none;
}
Loading

0 comments on commit 9b5b33f

Please sign in to comment.