diff --git a/.gitignore b/.gitignore index 5b2c87d1..2157fcc5 100644 --- a/.gitignore +++ b/.gitignore @@ -127,7 +127,7 @@ yarn.lock # ESLint Report eslint_report.json -modules/ +# modules/ tmp/ .env* bin/ diff --git a/ereuse_devicehub/modules/dpp/__init__.py b/ereuse_devicehub/modules/dpp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ereuse_devicehub/modules/dpp/alembic.ini b/ereuse_devicehub/modules/dpp/alembic.ini new file mode 100644 index 00000000..d8022031 --- /dev/null +++ b/ereuse_devicehub/modules/dpp/alembic.ini @@ -0,0 +1,74 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +#truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/ereuse_devicehub/modules/dpp/commands/register_user_dlt.py b/ereuse_devicehub/modules/dpp/commands/register_user_dlt.py new file mode 100644 index 00000000..df9505d1 --- /dev/null +++ b/ereuse_devicehub/modules/dpp/commands/register_user_dlt.py @@ -0,0 +1,34 @@ +import json + +import click + +from ereuse_devicehub.db import db +from ereuse_devicehub.resources.user.models import User + + +class RegisterUserDlt: + # "Operator", "Verifier" or "Witness" + + def __init__(self, app) -> None: + super().__init__() + self.app = app + help = "Register user in Dlt with params: email password rols" + self.app.cli.command('dlt_register_user', short_help=help)(self.run) + + @click.argument('email') + @click.argument('password') + @click.argument('rols') + def run(self, email, password, rols): + if not rols: + rols = "Operator" + user = User.query.filter_by(email=email).one() + + token_dlt = user.set_new_dlt_keys(password) + result = user.allow_permitions(api_token=token_dlt, rols=rols) + rols = user.get_rols(token_dlt=token_dlt) + rols = [k for k, v in rols] + user.rols_dlt = json.dumps(rols) + + db.session.commit() + + return result, rols diff --git a/ereuse_devicehub/modules/dpp/migrations/README b/ereuse_devicehub/modules/dpp/migrations/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/ereuse_devicehub/modules/dpp/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/ereuse_devicehub/modules/dpp/migrations/script.py.mako b/ereuse_devicehub/modules/dpp/migrations/script.py.mako new file mode 100644 index 00000000..3fbbfa7f --- /dev/null +++ b/ereuse_devicehub/modules/dpp/migrations/script.py.mako @@ -0,0 +1,33 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils +import citext +import teal +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def get_inv(): + INV = context.get_x_argument(as_dictionary=True).get('inventory') + if not INV: + raise ValueError("Inventory value is not specified") + return INV + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/ereuse_devicehub/modules/dpp/scripts/__init__.py b/ereuse_devicehub/modules/dpp/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ereuse_devicehub/modules/dpp/scripts/register_user_dlt.py b/ereuse_devicehub/modules/dpp/scripts/register_user_dlt.py new file mode 100644 index 00000000..d7b7ab41 --- /dev/null +++ b/ereuse_devicehub/modules/dpp/scripts/register_user_dlt.py @@ -0,0 +1,19 @@ +import json + +from ereuse_devicehub.db import db +from ereuse_devicehub.resources.user.models import User + + +def register_user(email, password, rols="Operator"): + # rols = 'Issuer, Operator, Witness, Verifier' + user = User.query.filter_by(email=email).one() + + token_dlt = user.set_new_dlt_keys(password) + result = user.allow_permitions(api_token=token_dlt, rols=rols) + rols = user.get_rols(token_dlt=token_dlt) + rols = [k for k, v in rols] + user.rols_dlt = json.dumps(rols) + + db.session.commit() + + return result, rols diff --git a/ereuse_devicehub/modules/dpp/scripts/register_user_dlt2.py b/ereuse_devicehub/modules/dpp/scripts/register_user_dlt2.py new file mode 100644 index 00000000..b5031d6b --- /dev/null +++ b/ereuse_devicehub/modules/dpp/scripts/register_user_dlt2.py @@ -0,0 +1,63 @@ +import json +import sys + +from decouple import config +from ereuseapi.methods import API, register_user + +from ereuse_devicehub.db import db +from ereuse_devicehub.devicehub import Devicehub +from ereuse_devicehub.modules.dpp.utils import encrypt +from ereuse_devicehub.resources.user.models import User + + +def main(): + email = sys.argv[1] + password = sys.argv[2] + schema = config('DB_SCHEMA') + app = Devicehub(inventory=schema) + app.app_context().push() + api_dlt = app.config.get('API_DLT') + keyUser1 = app.config.get('API_DLT_TOKEN') + + user = User.query.filter_by(email=email).one() + + data = register_user(api_dlt) + api_token = data.get('data', {}).get('api_token') + data = json.dumps(data) + user.api_keys_dlt = encrypt(password, data) + result = allow_permitions(keyUser1, api_dlt, api_token) + rols = get_rols(api_dlt, api_token) + user.rols_dlt = json.dumps(rols) + + db.session.commit() + + return result, rols + + +def get_rols(api_dlt, token_dlt): + api = API(api_dlt, token_dlt, "ethereum") + + result = api.check_user_roles() + if result.get('Status') != 200: + return [] + + if 'Success' not in result.get('Data', {}).get('status'): + return [] + + rols = result.get('Data', {}).get('data', {}) + return [k for k, v in rols.items() if v] + + +def allow_permitions(keyUser1, api_dlt, token_dlt): + apiUser1 = API(api_dlt, keyUser1, "ethereum") + rols = "isOperator" + if len(sys.argv) > 3: + rols = sys.argv[3] + + result = apiUser1.issue_credential(rols, token_dlt) + return result + + +if __name__ == '__main__': + # ['isIssuer', 'isOperator', 'isWitness', 'isVerifier'] + main() diff --git a/ereuse_devicehub/modules/dpp/users.py b/ereuse_devicehub/modules/dpp/users.py new file mode 100644 index 00000000..47bc9982 --- /dev/null +++ b/ereuse_devicehub/modules/dpp/users.py @@ -0,0 +1,9 @@ +from ereuse_devicehub.db import db +from ereuse_devicehub.resources.user.models import User + + +def set_dlt_user(email, password): + u = User.query.filter_by(email=email).one() + api_token = u.set_new_dlt_keys(password) + u.allow_permitions(api_token) + db.session.commit() diff --git a/ereuse_devicehub/modules/dpp/utils.py b/ereuse_devicehub/modules/dpp/utils.py new file mode 100644 index 00000000..8bc9219d --- /dev/null +++ b/ereuse_devicehub/modules/dpp/utils.py @@ -0,0 +1,17 @@ +import base64 + +from cryptography.fernet import Fernet + + +def encrypt(key, msg): + key = (key * 32)[:32] + key = base64.urlsafe_b64encode(key.encode()) + f = Fernet(key) + return f.encrypt(msg.encode()).decode() + + +def decrypt(key, msg): + key = (key * 32)[:32] + key = base64.urlsafe_b64encode(key.encode()) + f = Fernet(key) + return f.decrypt(msg.encode()).decode() diff --git a/ereuse_devicehub/modules/dpp/views.py b/ereuse_devicehub/modules/dpp/views.py new file mode 100644 index 00000000..e39e8ba3 --- /dev/null +++ b/ereuse_devicehub/modules/dpp/views.py @@ -0,0 +1,3 @@ +from flask import Blueprint + +dpp = Blueprint('dpp', __name__, template_folder='templates') diff --git a/ereuse_devicehub/modules/oidc/__init__.py b/ereuse_devicehub/modules/oidc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ereuse_devicehub/modules/oidc/alembic.ini b/ereuse_devicehub/modules/oidc/alembic.ini new file mode 100644 index 00000000..d8022031 --- /dev/null +++ b/ereuse_devicehub/modules/oidc/alembic.ini @@ -0,0 +1,74 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +#truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/ereuse_devicehub/modules/oidc/commands/add_member.py b/ereuse_devicehub/modules/oidc/commands/add_member.py new file mode 100644 index 00000000..d384c143 --- /dev/null +++ b/ereuse_devicehub/modules/oidc/commands/add_member.py @@ -0,0 +1,24 @@ +import click + +from ereuse_devicehub.db import db +from ereuse_devicehub.modules.oidc.models import MemberFederated + + +class AddMember: + def __init__(self, app) -> None: + super().__init__() + self.app = app + help = "Add member to the federated net" + self.app.cli.command('dlt_add_member', short_help=help)(self.run) + + @click.argument('dlt_id_provider') + @click.argument('domain') + def run(self, dlt_id_provider, domain): + member = MemberFederated.query.filter_by(domain=domain).first() + if member: + return + + member = MemberFederated(domain=domain, dlt_id_provider=dlt_id_provider) + + db.session.add(member) + db.session.commit() diff --git a/ereuse_devicehub/modules/oidc/commands/client_member.py b/ereuse_devicehub/modules/oidc/commands/client_member.py new file mode 100644 index 00000000..345b75a7 --- /dev/null +++ b/ereuse_devicehub/modules/oidc/commands/client_member.py @@ -0,0 +1,25 @@ +import click + +from ereuse_devicehub.db import db +from ereuse_devicehub.modules.oidc.models import MemberFederated + + +class AddClientOidc: + def __init__(self, app) -> None: + super().__init__() + self.app = app + help = "Add client oidc" + self.app.cli.command('add_client_oidc', short_help=help)(self.run) + + @click.argument('domain') + @click.argument('client_id') + @click.argument('client_secret') + def run(self, domain, client_id, client_secret): + member = MemberFederated.query.filter_by(domain=domain).first() + if not member: + return + + member.client_id = client_id + member.client_secret = client_secret + + db.session.commit() diff --git a/ereuse_devicehub/modules/oidc/commands/insert_member_in_dlt.py b/ereuse_devicehub/modules/oidc/commands/insert_member_in_dlt.py new file mode 100644 index 00000000..e1c464bb --- /dev/null +++ b/ereuse_devicehub/modules/oidc/commands/insert_member_in_dlt.py @@ -0,0 +1,28 @@ +import click +import requests +from decouple import config + + +class InsertMember: + def __init__(self, app) -> None: + super().__init__() + self.app = app + help = 'Add a new members to api dlt.' + self.app.cli.command('dlt_insert_members', short_help=help)(self.run) + + @click.argument('domain') + def run(self, domain): + api = config("API_RESOLVER", None) + if "http" not in domain: + print("Error: you need put https:// in domain") + return + + if not api: + print("Error: you need a entry var API_RESOLVER in .env") + return + + data = {"url": domain} + url = api + '/registerURL' + res = requests.post(url, json=data) + print(res.json()) + return diff --git a/ereuse_devicehub/modules/oidc/commands/sync_dlt.py b/ereuse_devicehub/modules/oidc/commands/sync_dlt.py new file mode 100644 index 00000000..585a1803 --- /dev/null +++ b/ereuse_devicehub/modules/oidc/commands/sync_dlt.py @@ -0,0 +1,45 @@ +import requests +from decouple import config + +from ereuse_devicehub.db import db +from ereuse_devicehub.modules.oidc.models import MemberFederated + + +class GetMembers: + def __init__(self, app) -> None: + super().__init__() + self.app = app + self.app.cli.command( + 'dlt_rsync_members', short_help='Synchronize members of dlt.' + )(self.run) + + def run(self): + api = config("API_RESOLVER", None) + if not api: + print("Error: you need a entry var API_RESOLVER in .env") + return + + url = api + '/getAll' + res = requests.get(url) + if res.status_code != 200: + return "Error, {}".format(res.text) + response = res.json() + members = response['url'] + counter = members.pop('counter') + if counter <= MemberFederated.query.count(): + return "All ok" + + for k, v in members.items(): + id = self.clean_id(k) + member = MemberFederated.query.filter_by(dlt_id_provider=id).first() + if member: + if member.domain != v: + member.domain = v + continue + member = MemberFederated(dlt_id_provider=id, domain=v) + db.session.add(member) + db.session.commit() + return res.text + + def clean_id(self, id): + return int(id.split('DH')[-1]) diff --git a/ereuse_devicehub/modules/oidc/forms.py b/ereuse_devicehub/modules/oidc/forms.py new file mode 100644 index 00000000..f63f330e --- /dev/null +++ b/ereuse_devicehub/modules/oidc/forms.py @@ -0,0 +1,159 @@ +import time + +from flask import g, request, session +from flask_wtf import FlaskForm +from werkzeug.security import gen_salt +from wtforms import ( + BooleanField, + SelectField, + StringField, + TextAreaField, + URLField, + validators, +) + +from ereuse_devicehub.db import db +from ereuse_devicehub.modules.oidc.models import MemberFederated, OAuth2Client + +AUTH_METHODS = [ + ('client_secret_basic', 'Client Secret Basic'), + ('client_secret_post', 'Client Secret Post'), + ('none', ''), +] + + +def split_by_crlf(s): + return [v for v in s.splitlines() if v] + + +class CreateClientForm(FlaskForm): + client_name = StringField( + 'Client Name', description="", render_kw={'class': "form-control"} + ) + client_uri = URLField( + 'Client url', description="", render_kw={'class': "form-control"} + ) + scope = StringField( + 'Allowed Scope', description="", render_kw={'class': "form-control"} + ) + redirect_uris = TextAreaField( + 'Redirect URIs', description="", render_kw={'class': "form-control"} + ) + grant_types = TextAreaField( + 'Allowed Grant Types', description="", render_kw={'class': "form-control"} + ) + response_types = TextAreaField( + 'Allowed Response Types', description="", render_kw={'class': "form-control"} + ) + token_endpoint_auth_method = SelectField( + 'Token Endpoint Auth Method', + choices=AUTH_METHODS, + description="", + render_kw={'class': "form-control, form-select"}, + ) + + def __init__(self, *args, **kwargs): + user = g.user + self.client = OAuth2Client.query.filter_by(user_id=user.id).first() + if request.method == 'GET': + if hasattr(self.client, 'client_metadata'): + kwargs.update(self.client.client_metadata) + grant_types = '\n'.join(kwargs.get('grant_types', ["authorization_code"])) + redirect_uris = '\n'.join(kwargs.get('redirect_uris', [])) + response_types = '\n'.join(kwargs.get('response_types', ["code"])) + kwargs['grant_types'] = grant_types + kwargs['redirect_uris'] = redirect_uris + kwargs['response_types'] = response_types + + super().__init__(*args, **kwargs) + + def validate(self, extra_validators=None): + is_valid = super().validate(extra_validators) + + if not is_valid: + return False + + domain = self.client_uri.data + self.member = MemberFederated.query.filter_by(domain=domain).first() + if not self.member: + txt = ["This domain is not federated."] + self.client_uri.errors = txt + return False + + if self.member.user and self.member.user != g.user: + txt = ["This domain is register from other user."] + self.client_uri.errors = txt + return False + return True + + def save(self): + if not self.client: + client_id = gen_salt(24) + self.client = OAuth2Client(client_id=client_id, user_id=g.user.id) + self.client.client_id_issued_at = int(time.time()) + + if self.token_endpoint_auth_method.data == 'none': + self.client.client_secret = '' + elif not self.client.client_secret: + self.client.client_secret = gen_salt(48) + + self.member.client_id = self.client.client_id + self.member.client_secret = self.client.client_secret + if not self.member.user: + self.member.user = g.user + + client_metadata = { + "client_name": self.client_name.data, + "client_uri": self.client_uri.data, + "grant_types": split_by_crlf(self.grant_types.data), + "redirect_uris": split_by_crlf(self.redirect_uris.data), + "response_types": split_by_crlf(self.response_types.data), + "scope": self.scope.data, + "token_endpoint_auth_method": self.token_endpoint_auth_method.data, + } + self.client.set_client_metadata(client_metadata) + self.client.member_id = self.member.dlt_id_provider + + if not self.client.id: + db.session.add(self.client) + + db.session.commit() + return self.client + + +class AuthorizeForm(FlaskForm): + consent = BooleanField( + 'Consent?', [validators.Optional()], default=False, description="" + ) + + +class ListInventoryForm(FlaskForm): + inventory = SelectField( + 'Select your inventory', + choices=[], + description="", + render_kw={'class': "form-control, form-select"}, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.inventories = MemberFederated.query.filter( + MemberFederated.client_id.isnot(None), + MemberFederated.client_secret.isnot(None), + ) + for i in self.inventories: + self.inventory.choices.append((i.dlt_id_provider, i.domain)) + + def save(self): + next = request.args.get('next', '') + iv = self.inventories.filter_by(dlt_id_provider=self.inventory.data).first() + + if not iv: + return next + + session['next_url'] = next + session['oidc'] = iv.dlt_id_provider + client_id = iv.client_id + dh = iv.domain + f'/oauth/authorize?client_id={client_id}' + dh += '&scope=openid+profile+rols&response_type=code&nonce=abc' + return dh diff --git a/ereuse_devicehub/modules/oidc/migrations/README b/ereuse_devicehub/modules/oidc/migrations/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/ereuse_devicehub/modules/oidc/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/ereuse_devicehub/modules/oidc/migrations/script.py.mako b/ereuse_devicehub/modules/oidc/migrations/script.py.mako new file mode 100644 index 00000000..3fbbfa7f --- /dev/null +++ b/ereuse_devicehub/modules/oidc/migrations/script.py.mako @@ -0,0 +1,33 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils +import citext +import teal +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def get_inv(): + INV = context.get_x_argument(as_dictionary=True).get('inventory') + if not INV: + raise ValueError("Inventory value is not specified") + return INV + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/ereuse_devicehub/modules/oidc/migrations/versions/abba37ff5c80_user_validation.py b/ereuse_devicehub/modules/oidc/migrations/versions/abba37ff5c80_user_validation.py new file mode 100644 index 00000000..e0f401df --- /dev/null +++ b/ereuse_devicehub/modules/oidc/migrations/versions/abba37ff5c80_user_validation.py @@ -0,0 +1,175 @@ +"""Open Connect OIDC + +Revision ID: abba37ff5c80 +Revises: +Create Date: 2022-09-30 10:01:19.761864 + +""" +import citext +import sqlalchemy as sa +from alembic import context, op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'abba37ff5c80' +down_revision = None +branch_labels = None +depends_on = None + + +def get_inv(): + INV = context.get_x_argument(as_dictionary=True).get('inventory') + if not INV: + raise ValueError("Inventory value is not specified") + return INV + + +def upgrade(): + op.create_table( + 'member_federated', + sa.Column('dlt_id_provider', sa.BigInteger(), nullable=False), + sa.Column( + 'updated', + sa.TIMESTAMP(timezone=True), + server_default=sa.text('CURRENT_TIMESTAMP'), + nullable=False, + ), + sa.Column( + 'created', + sa.TIMESTAMP(timezone=True), + server_default=sa.text('CURRENT_TIMESTAMP'), + nullable=False, + ), + sa.Column('domain', citext.CIText(), nullable=False), + sa.Column('client_id', citext.CIText(), nullable=True), + sa.Column('client_secret', citext.CIText(), nullable=True), + sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.PrimaryKeyConstraint('dlt_id_provider'), + sa.ForeignKeyConstraint(['user_id'], ['common.user.id'], ondelete='CASCADE'), + schema=f'{get_inv()}', + ) + + op.create_table( + 'oauth2_client', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column( + 'updated', + sa.TIMESTAMP(timezone=True), + server_default=sa.text('CURRENT_TIMESTAMP'), + nullable=False, + ), + sa.Column( + 'created', + sa.TIMESTAMP(timezone=True), + server_default=sa.text('CURRENT_TIMESTAMP'), + nullable=False, + ), + sa.Column('client_id_issued_at', sa.BigInteger(), nullable=False), + sa.Column('client_secret_expires_at', sa.BigInteger(), nullable=False), + sa.Column('client_id', citext.CIText(), nullable=False), + sa.Column('client_secret', citext.CIText(), nullable=False), + sa.Column('client_metadata', citext.CIText(), nullable=False), + sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('member_id', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['common.user.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint( + ['member_id'], + [f'{get_inv()}.member_federated.dlt_id_provider'], + ondelete='CASCADE', + ), + schema=f'{get_inv()}', + ) + + op.create_table( + 'oauth2_code', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column( + 'updated', + sa.TIMESTAMP(timezone=True), + server_default=sa.text('CURRENT_TIMESTAMP'), + nullable=False, + ), + sa.Column( + 'created', + sa.TIMESTAMP(timezone=True), + server_default=sa.text('CURRENT_TIMESTAMP'), + nullable=False, + ), + sa.Column('client_id', citext.CIText(), nullable=True), + sa.Column('code', citext.CIText(), nullable=False), + sa.Column('redirect_uri', citext.CIText(), nullable=True), + sa.Column('response_type', citext.CIText(), nullable=True), + sa.Column('scope', citext.CIText(), nullable=True), + sa.Column('nonce', citext.CIText(), nullable=True), + sa.Column('code_challenge', citext.CIText(), nullable=True), + sa.Column('code_challenge_method', citext.CIText(), nullable=True), + sa.Column('auth_time', sa.BigInteger(), nullable=False), + sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('member_id', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['common.user.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint( + ['member_id'], + [f'{get_inv()}.member_federated.dlt_id_provider'], + ondelete='CASCADE', + ), + sa.UniqueConstraint('code'), + schema=f'{get_inv()}', + ) + + op.create_table( + 'oauth2_token', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column( + 'updated', + sa.TIMESTAMP(timezone=True), + server_default=sa.text('CURRENT_TIMESTAMP'), + nullable=False, + ), + sa.Column( + 'created', + sa.TIMESTAMP(timezone=True), + server_default=sa.text('CURRENT_TIMESTAMP'), + nullable=False, + ), + sa.Column('client_id', citext.CIText(), nullable=True), + sa.Column('token_type', citext.CIText(), nullable=True), + sa.Column('access_token', citext.CIText(), nullable=False), + sa.Column('refresh_token', citext.CIText(), nullable=True), + sa.Column('scope', citext.CIText(), nullable=True), + sa.Column('issued_at', sa.BigInteger(), nullable=False), + sa.Column('access_token_revoked_at', sa.BigInteger(), nullable=False), + sa.Column('refresh_token_revoked_at', sa.BigInteger(), nullable=False), + sa.Column('expires_in', sa.BigInteger(), nullable=False), + sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('member_id', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['common.user.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint( + ['member_id'], + [f'{get_inv()}.member_federated.dlt_id_provider'], + ondelete='CASCADE', + ), + sa.UniqueConstraint('access_token'), + schema=f'{get_inv()}', + ) + + op.execute(f"CREATE SEQUENCE {get_inv()}.oauth2_client_seq;") + op.execute(f"CREATE SEQUENCE {get_inv()}.member_federated_seq;") + op.execute(f"CREATE SEQUENCE {get_inv()}.oauth2_code_seq;") + op.execute(f"CREATE SEQUENCE {get_inv()}.oauth2_token_seq;") + + +def downgrade(): + op.drop_table('oauth2_client', schema=f'{get_inv()}') + op.execute(f"DROP SEQUENCE {get_inv()}.oauth2_client_seq;") + + op.drop_table('oauth2_code', schema=f'{get_inv()}') + op.execute(f"DROP SEQUENCE {get_inv()}.oauth2_code_seq;") + + op.drop_table('oauth2_token', schema=f'{get_inv()}') + op.execute(f"DROP SEQUENCE {get_inv()}.oauth2_token_seq;") + + op.drop_table('member_federated', schema=f'{get_inv()}') + op.execute(f"DROP SEQUENCE {get_inv()}.member_federated_seq;") diff --git a/ereuse_devicehub/modules/oidc/models.py b/ereuse_devicehub/modules/oidc/models.py new file mode 100644 index 00000000..790e03a9 --- /dev/null +++ b/ereuse_devicehub/modules/oidc/models.py @@ -0,0 +1,76 @@ +from authlib.integrations.sqla_oauth2 import ( + OAuth2AuthorizationCodeMixin, + OAuth2ClientMixin, + OAuth2TokenMixin, +) +from flask import g + +from ereuse_devicehub.db import db +from ereuse_devicehub.resources.models import Thing +from ereuse_devicehub.resources.user.models import User + + +class MemberFederated(Thing): + __tablename__ = 'member_federated' + + dlt_id_provider = db.Column(db.Integer, primary_key=True) + domain = db.Column(db.String(40), unique=False) + # This client_id and client_secret is used for connected to this domain as + # a client and this domain then is the server of auth + client_id = db.Column(db.String(40), unique=False, nullable=True) + client_secret = db.Column(db.String(60), unique=False, nullable=True) + user_id = db.Column( + db.UUID(as_uuid=True), db.ForeignKey(User.id, ondelete='CASCADE'), nullable=True + ) + user = db.relationship(User) + + def __str__(self): + return self.domain + + +class OAuth2Client(Thing, OAuth2ClientMixin): + __tablename__ = 'oauth2_client' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.UUID(as_uuid=True), + db.ForeignKey(User.id, ondelete='CASCADE'), + nullable=False, + default=lambda: g.user.id, + ) + user = db.relationship(User) + member_id = db.Column( + db.Integer, + db.ForeignKey('member_federated.dlt_id_provider', ondelete='CASCADE'), + ) + member = db.relationship(MemberFederated) + + +class OAuth2AuthorizationCode(Thing, OAuth2AuthorizationCodeMixin): + __tablename__ = 'oauth2_code' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.UUID(as_uuid=True), db.ForeignKey(User.id, ondelete='CASCADE') + ) + user = db.relationship(User) + member_id = db.Column( + db.Integer, + db.ForeignKey('member_federated.dlt_id_provider', ondelete='CASCADE'), + ) + member = db.relationship('MemberFederated') + + +class OAuth2Token(Thing, OAuth2TokenMixin): + __tablename__ = 'oauth2_token' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.UUID(as_uuid=True), db.ForeignKey(User.id, ondelete='CASCADE') + ) + user = db.relationship(User) + member_id = db.Column( + db.Integer, + db.ForeignKey('member_federated.dlt_id_provider', ondelete='CASCADE'), + ) + member = db.relationship('MemberFederated') diff --git a/ereuse_devicehub/modules/oidc/oauth2.py b/ereuse_devicehub/modules/oidc/oauth2.py new file mode 100644 index 00000000..68563efb --- /dev/null +++ b/ereuse_devicehub/modules/oidc/oauth2.py @@ -0,0 +1,171 @@ +from authlib.integrations.flask_oauth2 import ( + AuthorizationServer as _AuthorizationServer, +) +from authlib.integrations.flask_oauth2 import ResourceProtector +from authlib.integrations.sqla_oauth2 import ( + create_bearer_token_validator, + create_query_client_func, + create_save_token_func, +) +from authlib.oauth2.rfc6749.grants import ( + AuthorizationCodeGrant as _AuthorizationCodeGrant, +) +from authlib.oidc.core import UserInfo +from authlib.oidc.core.grants import OpenIDCode as _OpenIDCode +from authlib.oidc.core.grants import OpenIDHybridGrant as _OpenIDHybridGrant +from authlib.oidc.core.grants import OpenIDImplicitGrant as _OpenIDImplicitGrant +from decouple import config +from werkzeug.security import gen_salt + +from ereuse_devicehub.db import db +from ereuse_devicehub.resources.user.models import User + +from .models import OAuth2AuthorizationCode, OAuth2Client, OAuth2Token + +DUMMY_JWT_CONFIG = { + 'key': config('SECRET_KEY'), + 'alg': 'HS256', + 'iss': config("HOST", 'https://authlib.org'), + 'exp': 3600, +} + + +def exists_nonce(nonce, req): + exists = OAuth2AuthorizationCode.query.filter_by( + client_id=req.client_id, nonce=nonce + ).first() + return bool(exists) + + +def generate_user_info(user, scope): + if 'rols' in scope: + rols = user.get_rols_dlt() + return UserInfo(rols=rols, sub=str(user.id), name=user.email) + return UserInfo(sub=str(user.id), name=user.email) + + +def create_authorization_code(client, grant_user, request): + code = gen_salt(48) + nonce = request.data.get('nonce') + item = OAuth2AuthorizationCode( + code=code, + client_id=client.client_id, + redirect_uri=request.redirect_uri, + scope=request.scope, + user_id=grant_user.id, + nonce=nonce, + member_id=client.member_id, + ) + db.session.add(item) + db.session.commit() + return code + + +class AuthorizationCodeGrant(_AuthorizationCodeGrant): + def create_authorization_code(self, client, grant_user, request): + return create_authorization_code(client, grant_user, request) + + def parse_authorization_code(self, code, client): + item = OAuth2AuthorizationCode.query.filter_by( + code=code, client_id=client.client_id + ).first() + if item and not item.is_expired(): + return item + + def delete_authorization_code(self, authorization_code): + db.session.delete(authorization_code) + db.session.commit() + + def authenticate_user(self, authorization_code): + return User.query.get(authorization_code.user_id) + + def save_authorization_code(self, code, request): + if not request.data.get('consent'): + return code + + item = OAuth2AuthorizationCode( + code=code, + client_id=request.client.client_id, + redirect_uri=request.redirect_uri, + scope=request.scope, + user_id=request.user.id, + nonce=request.data.get('nonce'), + member_id=request.client.member_id, + ) + db.session.add(item) + db.session.commit() + return code + + def query_authorization_code(self, code, client): + return OAuth2AuthorizationCode.query.filter_by( + code=code, client_id=client.client_id + ).first() + + +class OpenIDCode(_OpenIDCode): + def exists_nonce(self, nonce, request): + return exists_nonce(nonce, request) + + def get_jwt_config(self, grant): + return DUMMY_JWT_CONFIG + + def generate_user_info(self, user, scope): + return generate_user_info(user, scope) + + +class ImplicitGrant(_OpenIDImplicitGrant): + def exists_nonce(self, nonce, request): + return exists_nonce(nonce, request) + + def get_jwt_config(self, grant): + return DUMMY_JWT_CONFIG + + def generate_user_info(self, user, scope): + return generate_user_info(user, scope) + + +class HybridGrant(_OpenIDHybridGrant): + def create_authorization_code(self, client, grant_user, request): + return create_authorization_code(client, grant_user, request) + + def exists_nonce(self, nonce, request): + return exists_nonce(nonce, request) + + def get_jwt_config(self): + return DUMMY_JWT_CONFIG + + def generate_user_info(self, user, scope): + return generate_user_info(user, scope) + + +class AuthorizationServer(_AuthorizationServer): + def validate_consent_request(self, request=None, end_user=None): + return self.get_consent_grant(request=request, end_user=end_user) + + def save_token(self, token, request): + token['member_id'] = request.client.member_id + return super().save_token(token, request) + + +authorization = AuthorizationServer() +require_oauth = ResourceProtector() + + +def config_oauth(app): + query_client = create_query_client_func(db.session, OAuth2Client) + save_token = create_save_token_func(db.session, OAuth2Token) + authorization.init_app(app, query_client=query_client, save_token=save_token) + + # support all openid grants + authorization.register_grant( + AuthorizationCodeGrant, + [ + OpenIDCode(require_nonce=True), + ], + ) + authorization.register_grant(ImplicitGrant) + authorization.register_grant(HybridGrant) + + # protect resource + bearer_cls = create_bearer_token_validator(db.session, OAuth2Token) + require_oauth.register_token_validator(bearer_cls()) diff --git a/ereuse_devicehub/modules/oidc/provider_discovery.py b/ereuse_devicehub/modules/oidc/provider_discovery.py new file mode 100644 index 00000000..7302f1b8 --- /dev/null +++ b/ereuse_devicehub/modules/oidc/provider_discovery.py @@ -0,0 +1,22 @@ +discovery = { + "issuer": "{ host }", + "authorization_endpoint": "{ host }/oauth/authorize", + "token_endpoint": "{ host }/oauth/token", + "token_endpoint_auth_methods_supported": ["client_secret_basic", "private_key_jwt"], + "token_endpoint_auth_signing_alg_values_supported": ["RS256", "ES256"], + "userinfo_endpoint": "{ host }/oauth/userinfo", + "scopes_supported": ["openid", "profile", "rols"], + "response_types_supported": ["code", "code id_token", "id_token", "token id_token"], + "userinfo_signing_alg_values_supported": ["RS256", "ES256", "HS256"], + "userinfo_encryption_alg_values_supported": ["RSA1_5", "A128KW"], + "userinfo_encryption_enc_values_supported": ["A128CBC-HS256", "A128GCM"], + "id_token_signing_alg_values_supported": ["RS256", "ES256", "HS256"], + "id_token_encryption_alg_values_supported": ["RSA1_5", "A128KW"], + "id_token_encryption_enc_values_supported": ["A128CBC-HS256", "A128GCM"], + "request_object_signing_alg_values_supported": ["none", "RS256", "ES256"], + "display_values_supported": ["page", "popup"], + "claim_types_supported": ["normal", "distributed"], + "claims_supported": [], + "claims_parameter_supported": True, + "ui_locales_supported": ["en-US"], +} diff --git a/ereuse_devicehub/modules/oidc/scripts/add_member.py b/ereuse_devicehub/modules/oidc/scripts/add_member.py new file mode 100644 index 00000000..5f6de6d1 --- /dev/null +++ b/ereuse_devicehub/modules/oidc/scripts/add_member.py @@ -0,0 +1,27 @@ +import sys + +from decouple import config + +from ereuse_devicehub.db import db +from ereuse_devicehub.devicehub import Devicehub +from ereuse_devicehub.modules.oidc.models import MemberFederated + + +def main(): + schema = config('DB_SCHEMA') + app = Devicehub(inventory=schema) + app.app_context().push() + dlt_id_provider = sys.argv[1] + domain = sys.argv[2] + member = MemberFederated.query.filter_by(domain=domain).first() + if member: + return + + member = MemberFederated(domain=domain, dlt_id_provider=dlt_id_provider) + + db.session.add(member) + db.session.commit() + + +if __name__ == '__main__': + main() diff --git a/ereuse_devicehub/modules/oidc/scripts/client_member.py b/ereuse_devicehub/modules/oidc/scripts/client_member.py new file mode 100644 index 00000000..474b8484 --- /dev/null +++ b/ereuse_devicehub/modules/oidc/scripts/client_member.py @@ -0,0 +1,32 @@ +import sys + +from decouple import config + +from ereuse_devicehub.db import db +from ereuse_devicehub.devicehub import Devicehub +from ereuse_devicehub.modules.oidc.models import MemberFederated + + +def main(): + """ + We need add client_id and client_secret for every server + than we want connect. + """ + schema = config('DB_SCHEMA') + app = Devicehub(inventory=schema) + app.app_context().push() + domain = sys.argv[1] + client_id = sys.argv[2] + client_secret = sys.argv[3] + member = MemberFederated.query.filter_by(domain=domain).first() + if not member: + return + + member.client_id = client_id + member.client_secret = client_secret + + db.session.commit() + + +if __name__ == '__main__': + main() diff --git a/ereuse_devicehub/modules/oidc/templates/authorize.html b/ereuse_devicehub/modules/oidc/templates/authorize.html new file mode 100644 index 00000000..3046a551 --- /dev/null +++ b/ereuse_devicehub/modules/oidc/templates/authorize.html @@ -0,0 +1,39 @@ +{% extends "ereuse_devicehub/base_site.html" %} +{% block main %} +
{{grant.client.client_name}} is requesting: + {{ grant.request.scope }} +
+