diff --git a/hiboo/__init__.py b/hiboo/__init__.py index 6fd5692d19fa6cce2f050e8e0787adcd821a30d2..470c7c6b7afa19ceb2abbffc0538537488713bf8 100644 --- a/hiboo/__init__.py +++ b/hiboo/__init__.py @@ -30,8 +30,10 @@ def create_app_from_config(config): return dict(config=app.config, utils=utils) # Import views - from hiboo import account, service, sso + from hiboo import account, user, identity, service, sso app.register_blueprint(account.blueprint, url_prefix='/account') + app.register_blueprint(user.blueprint, url_prefix='/user') + app.register_blueprint(identity.blueprint, url_prefix='/identity') app.register_blueprint(service.blueprint, url_prefix='/service') app.register_blueprint(sso.blueprint, url_prefix='/sso') diff --git a/hiboo/account/__init__.py b/hiboo/account/__init__.py index 125e097fed4688a5d9cdcf8ba9feb0ba22a01756..94a5a7de563864608d15580fd5bd2a3bb9342c24 100644 --- a/hiboo/account/__init__.py +++ b/hiboo/account/__init__.py @@ -1,6 +1,6 @@ -from flask import Blueprint +import flask -blueprint = Blueprint("account", __name__, template_folder="templates") +blueprint = flask.Blueprint("account", __name__, template_folder="templates") -from hiboo.account import login, profiles, settings +from hiboo.account import login, home, settings diff --git a/hiboo/account/forms.py b/hiboo/account/forms.py index 26598641c924b510ef18bc68185d616e1f46a20b..141d55fd4a91fff6e66b279049037d7c4bdcb75c 100644 --- a/hiboo/account/forms.py +++ b/hiboo/account/forms.py @@ -1,4 +1,4 @@ -from wtforms import validators, fields, widgets +from wtforms import validators, fields from flask_babel import lazy_gettext as _ import flask_wtf @@ -28,17 +28,3 @@ class PasswordForm(flask_wtf.FlaskForm): password2 = fields.PasswordField(_('Confirm new password'), [validators.DataRequired(), validators.EqualTo('password')]) submit = fields.SubmitField(_('Change password')) - - -class ProfileForm(flask_wtf.FlaskForm): - username = fields.StringField(_('Username'), [validators.DataRequired()]) - comment = fields.StringField(_('Comment')) - submit = fields.SubmitField(_('Create profile')) - - -class ProfilePickForm(flask_wtf.FlaskForm): - profile_uuid = fields.TextField('profile', []) - - -class AvatarForm(flask_wtf.FlaskForm): - submit = fields.SubmitField(_('Sign up'), []) diff --git a/hiboo/account/home.py b/hiboo/account/home.py new file mode 100644 index 0000000000000000000000000000000000000000..822fe98e67428aa1d7b092f2114e91ed56059bae --- /dev/null +++ b/hiboo/account/home.py @@ -0,0 +1,12 @@ +from hiboo.account import blueprint +from hiboo import security + +import flask +import flask_login + + +@blueprint.route("/home") +@security.authentication_required() +def home(): + history = flask_login.current_user.history + return flask.render_template("account_home.html", history=history) diff --git a/hiboo/account/profiles.py b/hiboo/account/profiles.py deleted file mode 100644 index a27af3fb732ed6bad0861e7c084daa30faf1b466..0000000000000000000000000000000000000000 --- a/hiboo/account/profiles.py +++ /dev/null @@ -1,131 +0,0 @@ -from hiboo.account import blueprint, forms -from hiboo.sso import forms as sso_forms -from hiboo import models, utils, security - -import flask -import flask_login - - -def get_profile(service, **redirect_args): - form = forms.ProfilePickForm() - if form.validate_on_submit(): - profile = models.Profile.query.get(form.profile_uuid.data) - if not (profile.user == flask_login.current_user and - profile.service == service and - profile.status == models.Profile.ACTIVE): - return None - return profile - next = ("account.pick_avatar" if service.max_profiles == 0 - else "account.pick_profile") - utils.force_redirect(utils.url_for(next, **redirect_args)) - - -@blueprint.route("/profile/pick") -@security.authentication_required() -def pick_profile(): - service_uuid = flask.request.args.get("service_uuid") or flask.abort(404) - service = models.Service.query.get(service_uuid) or flask.abort(404) - profiles = models.Profile.filter(service, flask_login.current_user).all() - form = forms.ProfilePickForm() - return flask.render_template("profile_pick.html", - service=service, profiles=profiles, form=form, - action_create=utils.url_for("account.create_profile", intent="account.pick_profile"), - action_pick=utils.url_or_intent("account.home")) - - -@blueprint.route("/profile/create", methods=["GET", "POST"]) -@security.authentication_required() -def create_profile(): - service_uuid = flask.request.args.get("service_uuid") or flask.abort(404) - service = models.Service.query.get(service_uuid) or flask.abort(404) - status = models.Profile.ACTIVE - profiles = models.Profile.filter(service, flask_login.current_user).all() - # Do not create profile for reserved or locked services - if service.policy in (models.Service.RESERVED, models.Service.LOCKED): - flask.flash("You cannot request a profile for this service", "danger") - return flask.redirect(flask.url_for("account.home")) - # Only burst services are allowed to exceed profile count - elif len(profiles) >= service.max_profiles and service.policy != models.Service.BURST: - flask.flash("Your reached the maximum number of profiles", "danger") - return flask.redirect(flask.url_for("account.home")) - # Managed services and bursting accounts require approval - elif len(profiles) >= service.max_profiles or service.policy == models.Service.MANAGED: - flask.flash("Your profile creation requires approval", "warning") - status = models.Profile.REQUEST - # Actually display the form - form = forms.ProfileForm() - if form.validate_on_submit(): - conflict = models.Profile.query.filter_by( - service_uuid=service_uuid, username=form.username.data - ).first() - if conflict: - flask.flash("A profile with that username exists already", "danger") - else: - profile = models.Profile() - profile.username = form.username.data - profile.user = flask_login.current_user - profile.service = service - profile.comment = form.comment.data - profile.status = status - models.db.session.add(profile) - models.log(models.History.CREATE, profile.username, profile.comment, - user=flask_login.current_user, service=service, profile=profile) - models.db.session.commit() - return flask.redirect(utils.url_or_intent("account.home")) - return flask.render_template("profile_create.html", form=form, service=service) - - -@blueprint.route("/avatar/pick") -@security.authentication_required() -def pick_avatar(): - service_uuid = flask.request.args.get("service_uuid") or flask.abort(404) - service = models.Service.query.get(service_uuid) or flask.abort(404) - avatar = models.Profile.filter(service, flask_login.current_user).first() - form = forms.ProfilePickForm() - if avatar: - if avatar.status == models.Profile.REQUEST: - flask.flash("Your account request is awaiting approval", "warning") - return flask.redirect(fflask.url_for("account.home")) - elif avatar.status == models.Profile.BLOCKED: - flask.flash("You are currently blocked", "danger") - return flask.redirect(fflask.url_for("account.home")) - elif avatar.status in (models.Profile.DELETED, models.Profile.UNCLAIMED): - flask.flash("Your avatar is unavailable", "danger") - return flask.redirect(fflask.url_for("account.home")) - elif service.policy not in (models.Service.OPEN, models.Service.MANAGED): - flask.flash("You cannot access this service", "danger") - return flask.redirect(fflask.url_for("account.home")) - else: - return flask.redirect(utils.url_for("account.create_avatar", intent="account.pick_avatar")) - return flask.render_template("avatar_pick.html", - service=service, avatar=avatar, form=form, - action_pick=utils.url_or_intent("account.home"), - action_create=utils.url_for("account.create_avatar", intent="account.pick_avatar")) - - -@blueprint.route("/avatar/create", methods=["GET", "POST"]) -@security.authentication_required() -def create_avatar(): - service_uuid = flask.request.args.get("service_uuid") or flask.abort(404) - service = models.Service.query.get(service_uuid) or flask.abort(404) - # Cannot create an avatar if one exists already - existing = models.Profile.filter(service, flask_login.current_user).first() - existing and flask.abort(403) - # Cannot create an avatar for anything but a managed or open service - if service.policy == models.Service.OPEN: - status = models.Profile.ACTIVE - elif service.policy == models.Service.MANAGED: - status = models.Profile.REQUEST - else: - flask.abort(403) - form = forms.AvatarForm() - if form.validate_on_submit(): - avatar = models.Profile() - avatar.username = flask_login.current_user.username - avatar.user = flask_login.current_user - avatar.service = service - avatar.status = models.Profile.ACTIVE - models.db.session.add(avatar) - models.db.session.commit() - return flask.redirect(utils.url_or_intent("account.home")) - return flask.render_template("avatar_create.html", form=form, service=service) diff --git a/hiboo/account/settings.py b/hiboo/account/settings.py index c9fcd271130146ed992bf4afc2212ac7f8897e9d..0680926c825bd72c440834009a555f6d7b101890 100644 --- a/hiboo/account/settings.py +++ b/hiboo/account/settings.py @@ -5,13 +5,6 @@ import flask import flask_login -@blueprint.route("/home") -@security.authentication_required() -def home(): - history = flask_login.current_user.history - return flask.render_template("account_home.html", history=history) - - @blueprint.route("/password", methods=["GET", "POST"]) @security.authentication_required() def password(): diff --git a/hiboo/account/templates/account_home.html b/hiboo/account/templates/account_home.html index 109482accc079314abc994d805b75da3aea2f328..99223ac5d4596d6f889e952e602a04b911ebbd55 100644 --- a/hiboo/account/templates/account_home.html +++ b/hiboo/account/templates/account_home.html @@ -12,7 +12,7 @@ {{ macros.infobox("Account age", "{} days".format((current_user.created_at.today() - current_user.created_at).total_seconds() // 86400), "aqua", "calendar") }} </div> <div class="col-md-6 col-xs-12"> - {{ macros.infobox("Profile count", current_user.profiles.__len__(), "red", "users") }} + {{ macros.infobox("Identity count", current_user.identities.__len__(), "red", "users") }} </div> </div> <div class="row"> diff --git a/hiboo/account/templates/avatar_create.html b/hiboo/account/templates/avatar_create.html deleted file mode 100644 index 6b38b12d02df337d916888301df3e549c7b13cf9..0000000000000000000000000000000000000000 --- a/hiboo/account/templates/avatar_create.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{% trans %}Sign-up{% endtrans %}{% endblock %} -{% block subtitle %}{% trans %}for the service {{ service.name }}{% endtrans %}{% endblock %} - -{% block content %} -<div class="box"> - <div class="box-body"> - <p>{% trans %}Your are about to sign up for {{ service.name }}{% endtrans %}.</p> - </div> - <form method="POST" action="{{ action_pick }}" class="form"> - {{ form.hidden_tag() }} - <input type="submit" value="Sign up" style="opacity: 0.8" class="btn btn-lg btn-flat bg-gray text-black"> - </form> -</div> -{% endblock %} diff --git a/hiboo/account/templates/avatar_pick.html b/hiboo/account/templates/avatar_pick.html deleted file mode 100644 index 0236ad1281cc59be23e0635dcb144601a1c4d058..0000000000000000000000000000000000000000 --- a/hiboo/account/templates/avatar_pick.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{% trans %}Sign in{% endtrans %}{% endblock %} -{% block subtitle %}{% trans %}for the service {{ service.name }}{% endtrans %}{% endblock %} - -{% block content %} -<div class="box"> - <div class="box-body"> - <p>{% trans %}Please confirm that you wish to sign in to {{ service.name }}.{% endtrans %}</p> - </div> - <form method="POST" action="{{ action_pick }}" class="form"> - {{ form.hidden_tag() }} - <input type="hidden" name="profile_uuid" value="{{ avatar.uuid }}"> - <input type="submit" value="Sign in" style="opacity: 0.8" class="btn btn-lg btn-flat bg-gray text-black"> - </form> -</div> -{% endblock %} diff --git a/hiboo/identity/__init__.py b/hiboo/identity/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ec4054a4e3e13fd178523c0a56a409ed505dec9c --- /dev/null +++ b/hiboo/identity/__init__.py @@ -0,0 +1,23 @@ +import flask + + +blueprint = flask.Blueprint("identity", __name__, template_folder="templates") + +import flask_login +from hiboo import models, utils +from hiboo.identity import profiles, avatars, forms + + +def get_identity(service, **redirect_args): + form = forms.IdentityPickForm() + if form.validate_on_submit(): + identity = models.Identity.query.get(form.identity_uuid.data) + if not (identity and + identity.user == flask_login.current_user and + identity.service == service and + identity.status == models.Identity.ACTIVE): + return None + return identity + next = ("identity.pick_avatar" if service.max_profiles == 0 + else "identity.pick_profile") + utils.force_redirect(utils.url_for(next, **redirect_args)) diff --git a/hiboo/identity/avatars.py b/hiboo/identity/avatars.py new file mode 100644 index 0000000000000000000000000000000000000000..4c248fab9799915f8ac7c453fb04a0f846ec4e53 --- /dev/null +++ b/hiboo/identity/avatars.py @@ -0,0 +1,57 @@ +from hiboo.identity import blueprint, forms +from hiboo import models, utils, security + +import flask +import flask_login + + +@blueprint.route("/avatar/pick/<service_uuid>") +@security.authentication_required() +def pick_avatar(service_uuid): + service = models.Service.query.get(service_uuid) or flask.abort(404) + service.max_profiles == 0 or flask.abort(404) + avatar = models.Identity.filter(service, flask_login.current_user).first() + form = forms.IdentityPickForm() + if avatar: + if avatar.status == models.Identity.REQUEST: + flask.flash("Your account request is awaiting approval", "warning") + return flask.redirect(fflask.url_for("account.home")) + elif avatar.status == models.Identity.BLOCKED: + flask.flash("You are currently blocked", "danger") + return flask.redirect(fflask.url_for("account.home")) + elif avatar.status in (models.Identity.DELETED, models.Identity.UNCLAIMED): + flask.flash("Your avatar is unavailable", "danger") + return flask.redirect(fflask.url_for("account.home")) + elif service.policy not in (models.Service.OPEN, models.Service.MANAGED): + flask.flash("You cannot access this service", "danger") + return flask.redirect(fflask.url_for("account.home")) + else: + return flask.redirect(utils.url_for("identity.create_avatar", intent=True)) + return flask.render_template("avatar_pick.html", service=service, avatar=avatar, form=form) + + +@blueprint.route("/avatar/create/<service_uuid>", methods=["GET", "POST"]) +@security.authentication_required() +def create_avatar(service_uuid): + service = models.Service.query.get(service_uuid) or flask.abort(404) + service.max_profiles == 0 or flask.abort(404) + # Cannot create an avatar if one exists already + models.Identity.filter(service, flask_login.current_user).first() and flask.abort(403) + # Cannot create an avatar for anything but a managed or open service + if service.policy == models.Service.OPEN: + status = models.Identity.ACTIVE + elif service.policy == models.Service.MANAGED: + status = models.Identity.REQUEST + else: + flask.abort(403) + form = forms.AvatarForm() + if form.validate_on_submit(): + avatar = models.Identity() + avatar.username = flask_login.current_user.username + avatar.user = flask_login.current_user + avatar.service = service + avatar.status = models.Identity.ACTIVE + models.db.session.add(avatar) + models.db.session.commit() + return flask.redirect(utils.url_or_intent("account.home")) + return flask.render_template("avatar_create.html", form=form, service=service) diff --git a/hiboo/identity/forms.py b/hiboo/identity/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..26963bc3f42405bcffd73d3e7a741175f1c11c82 --- /dev/null +++ b/hiboo/identity/forms.py @@ -0,0 +1,24 @@ +from wtforms import validators, fields +from flask_babel import lazy_gettext as _ + +import flask_wtf + + +class ProfileForm(flask_wtf.FlaskForm): + username = fields.StringField(_('Username'), [validators.DataRequired()]) + comment = fields.StringField(_('Comment')) + submit = fields.SubmitField(_('Create profile')) + + +class IdentityPickForm(flask_wtf.FlaskForm): + identity_uuid = fields.TextField('identity', []) + + +class AvatarForm(flask_wtf.FlaskForm): + submit = fields.SubmitField(_('Sign up'), []) + + +class ClaimForm(flask_wtf.FlaskForm): + username = fields.StringField(_('Username'), [validators.DataRequired()]) + password = fields.PasswordField(_('Password'), [validators.DataRequired()]) + submit = fields.SubmitField(_('Claim profile')) diff --git a/hiboo/identity/profiles.py b/hiboo/identity/profiles.py new file mode 100644 index 0000000000000000000000000000000000000000..75ec2f67a42b3b065740487feba75caf8a42fb02 --- /dev/null +++ b/hiboo/identity/profiles.py @@ -0,0 +1,107 @@ +from hiboo.identity import blueprint, forms +from hiboo import models, utils, security +from hiboo import user as hiboo_user +from passlib import context, hash + +import flask +import flask_login + + +@blueprint.route("/profile/pick/<service_uuid>") +@security.authentication_required() +def pick_profile(service_uuid): + service = models.Service.query.get(service_uuid) or flask.abort(404) + service.max_profiles > 0 or flask.abort(404) + profiles = models.Identity.filter(service, flask_login.current_user).all() + form = forms.IdentityPickForm() + return flask.render_template("profile_pick.html", service=service, profiles=profiles, form=form) + + +@blueprint.route("/profile/create_for/<service_uuid>", methods=["GET", "POST"], defaults={"create_for": True}, endpoint="create_profile_for") +@blueprint.route("/profile/create/<service_uuid>", methods=["GET", "POST"]) +@security.authentication_required() +def create_profile(service_uuid, create_for=False): + service = models.Service.query.get(service_uuid) or flask.abort(404) + service.max_profiles > 0 or flask.abort(404) + status = models.Identity.ACTIVE + # If the admin passed a user uuid, use that one, otherwise ignore it + if create_for and flask_login.current_user.is_admin: + user = hiboo_user.get_user(intent="identity.create_profile_for", create_for=None) + else: + user = flask_login.current_user + # Do not create profile for locked services + if service.policy == models.Service.LOCKED: + flask.flash("You cannot request a profile for this service", "danger") + return flask.redirect(flask.url_for("account.home")) + # Other restrictions do not apply to admins or managers + if not flask_login.current_user.is_admin: + profiles = models.Identity.filter(service, user).all() + # Do not create profile for locked services + if service.policy == models.Service.RESERVED: + flask.flash("You cannot request a profile for this service", "danger") + return flask.redirect(flask.url_for("account.home")) + # Only burst services are allowed to exceed profile count + elif len(profiles) >= service.max_profiles and service.policy != models.Service.BURST: + flask.flash("Your reached the maximum number of profiles", "danger") + return flask.redirect(flask.url_for("account.home")) + # Managed services and bursting accounts require approval + elif len(profiles) >= service.max_profiles or service.policy == models.Service.MANAGED: + flask.flash("Your profile creation requires approval", "warning") + status = models.Profile.REQUEST + # Actually display the form + form = forms.ProfileForm() + if form.validate_on_submit(): + existing = models.Identity.query.filter_by( + service_uuid=service_uuid, username=form.username.data + ).first() + if existing: + flask.flash("A profile with that username exists already", "danger") + else: + profile = models.Identity() + profile.username = form.username.data + profile.user = user + profile.service = service + profile.comment = form.comment.data + profile.status = status + models.db.session.add(profile) + models.log(models.History.CREATE, profile.username, profile.comment, + user=user, service=service, identity=profile) + models.db.session.commit() + return flask.redirect(utils.url_or_intent("account.home")) + return flask.render_template("profile_create.html", form=form, service=service, + user=user, create_for=create_for) + + +@blueprint.route("/profile/claim/<service_uuid>", methods=["GET", "POST"]) +@security.authentication_required() +def claim_profile(service_uuid): + service = models.Service.query.get(service_uuid) or flask.abort(404) + service.max_profiles > 0 or flask.abort(404) + form = forms.ClaimForm() + if form.validate_on_submit(): + profile = models.Identity.query.filter_by( + service_uuid=service_uuid, username=form.username.data, + status = models.Identity.UNCLAIMED + ).first() + check = context.CryptContext([ + scheme for scheme in dir(hash) if not scheme.startswith('__') + ]) + if profile and check.verify(form.password.data, profile.extra.get("password")): + profile.user = flask_login.current_user + profile.status = models.Identity.ACTIVE + del profile.extra["password"] + models.db.session.add(profile) + models.db.session.commit() + flask.flash("Successfully claimed the profile!", "success") + else: + flask.flash("Wrong username or password", "danger") + return flask.render_template("profile_claim.html", form=form, service=service) + + +@blueprint.route("/profile/service/<service_uuid>") +@security.admin_required() +def list_profiles(service_uuid=None): + service = models.Service.query.get(service_uuid) or flask.abort(404) + service.max_profiles > 0 or flask.abort(404) + return flask.render_template("profile_list.html", profiles=service.profiles, + service=service) diff --git a/hiboo/identity/templates/avatar_create.html b/hiboo/identity/templates/avatar_create.html new file mode 100644 index 0000000000000000000000000000000000000000..83cec6ee3d7bf49c9d698766508174e0421eaf2c --- /dev/null +++ b/hiboo/identity/templates/avatar_create.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Sign-up{% endtrans %}{% endblock %} +{% block subtitle %}{% trans service_name %}for the service {{ service_name }}{% endtrans %}{% endblock %} + +{% block content %} +<div class="box"> + <div class="box-body"> + <p>{% trans %}Your are about to sign up for {{ service.name }}{% endtrans %}.</p> + </div> + {{ macros.form(form) }} +</div> +{% endblock %} diff --git a/hiboo/identity/templates/avatar_pick.html b/hiboo/identity/templates/avatar_pick.html new file mode 100644 index 0000000000000000000000000000000000000000..e7efa23fdbca345047723089c6a9a21ff7e02c3a --- /dev/null +++ b/hiboo/identity/templates/avatar_pick.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Sign in{% endtrans %}{% endblock %} +{% block subtitle %}{% trans service_name %}for the service {{ service_name }}{% endtrans %}{% endblock %} + +{% block content %} +<div class="box"> + <div class="box-body"> + <p>{% trans %}Please confirm that you wish to sign in to {{ service.name }}.{% endtrans %}</p> + </div> + <form method="POST" action="{{ utils.url_or_intent("account.home") }}" class="form"> + {{ form.hidden_tag() }} + <input type="hidden" name="identity_uuid" value="{{ avatar.uuid }}"> + <input type="submit" value="Sign in" class="btn btn-lg btn-flat bg-gray text-black"> + </form> +</div> +{% endblock %} diff --git a/hiboo/identity/templates/profile_claim.html b/hiboo/identity/templates/profile_claim.html new file mode 100644 index 0000000000000000000000000000000000000000..fed37c23fe6b20419cbef5cb50b12f59675d9704 --- /dev/null +++ b/hiboo/identity/templates/profile_claim.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% set service_name = service.name %} +{% block title %}{% trans %}Claim profile{% endtrans %}{% endblock %} +{% block subtitle %}{% trans service_name %}for the service {{ service_name }}{% endtrans %}{% endblock %} + +{% block content %} + +{% call macros.help(_("Claim an existing profile"), auto=utils.display_help("main")) %} + <p>{% trans %}You are about to create your first profile.{% endtrans %}</p> + <p>{% trans %}Please choose a username wisely, you will not be able to change it later. You may pick anything that is valid for the service, as long as it is available.{% endtrans %}</p> + <p>{% trans %}If your profile request is submitted for validation, please add a useful comment that will help us validate (describe shortly what your profile is for or why you need one).{% endtrans %}</p> +{% endcall %} + +{{ macros.form(form) }} + +{% endblock %} diff --git a/hiboo/account/templates/profile_create.html b/hiboo/identity/templates/profile_create.html similarity index 79% rename from hiboo/account/templates/profile_create.html rename to hiboo/identity/templates/profile_create.html index 6d5e26fc3db74e6cec0c9ea194cdfec52e5ca7c8..408762a2b811e3be2435c490cccbd0a9a8a83c77 100644 --- a/hiboo/account/templates/profile_create.html +++ b/hiboo/identity/templates/profile_create.html @@ -2,7 +2,10 @@ {% set service_name = service.name %} {% block title %}{% trans %}New profile{% endtrans %}{% endblock %} -{% block subtitle %}{% trans service_name %}for the service {{ service_name }}{% endtrans %}{% endblock %} +{% block subtitle %} + {% trans service_name %}for the service {{ service_name }}{% endtrans %} + {% if create_for %}{% trans %}and user{% endtrans %} {{ user.username }}{% endif %} +{% endblock %} {% block content %} diff --git a/hiboo/identity/templates/profile_list.html b/hiboo/identity/templates/profile_list.html new file mode 100644 index 0000000000000000000000000000000000000000..762ef2df48de2ee364fa7b88482977e8db343c79 --- /dev/null +++ b/hiboo/identity/templates/profile_list.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Profile list{% endtrans %}{% endblock %} +{% block subtitle %}{% if service %}{{ service.name }}{% endif %}{% endblock %} + +{% block content %} +<div class="row"> + <div class="col-xs-12"> + <div class="box"> + <div class="box-body table-responsive no-padding"> + <table class="table table-hover"> + <tr> + <th>{% trans %}Service{% endtrans %}</th> + <th>{% trans %}User{% endtrans %}</th> + <th>{% trans %}Identity{% endtrans %}</th> + <th>{% trans %}Created on{% endtrans %}</th> + </tr> + {% for profile in profiles %} + <tr> + <td>{{ profile.service.name }}</td> + <td>{{ profile.user.username }}</td> + <td>{{ profile.username }}</td> + <td>{{ profile.created_on }}</td> + </tr> + {% endfor %} + </table> + </div> + </div> + </div> +</div> +{% endblock %} + +{% block actions %} +{% if service %} +<a href="{{ url_for(".create_profile_for", service_uuid=service.uuid) }}" class="btn btn-success">{% trans %}Create a profile{% endtrans %}</a> +{% endif %} +{% endblock %} diff --git a/hiboo/account/templates/profile_pick.html b/hiboo/identity/templates/profile_pick.html similarity index 85% rename from hiboo/account/templates/profile_pick.html rename to hiboo/identity/templates/profile_pick.html index ed86316812a5bfb351c6e601f3cf8d80dd2b7cac..41f19fe564353bd02c56667a90fbdf99f5694d4b 100644 --- a/hiboo/account/templates/profile_pick.html +++ b/hiboo/identity/templates/profile_pick.html @@ -13,9 +13,9 @@ <div class="box box-widget widget-user-2"> <div class="widget-user-header bg-{{ colors[loop.index0 % 7] }}"> {% if profile.status == "active" %} - <form method="POST" action="{{ action_pick }}" class="form"> + <form method="POST" action="{{ utils.url_or_intent("account.home") }}" class="form"> {{ form.hidden_tag() }} - <input type="hidden" name="profile_uuid" value="{{ profile.uuid }}"> + <input type="hidden" name="identity_uuid" value="{{ profile.uuid }}"> <input type="submit" value="Sign in" style="opacity: 0.8" class="btn btn-lg btn-flat bg-gray text-black pull-right"> </form> {% elif profile.status == "blocked" %} @@ -61,11 +61,14 @@ {% endblock %} {% block actions %} +{% if service.policy not in ("locked") %} +<a href="{{ utils.url_for(".claim_profile", intent=True) }}" class="btn btn-primary">Claim profile</a> +{% endif %} {% if service.policy in ("open", "burst") and profiles.__len__() < service.max_profiles %} -<a href="{{ utils.url_for(".create_profile") }}" class="btn btn-primary">Create profile</a> +<a href="{{ utils.url_for(".create_profile", intent=True) }}" class="btn btn-success">Create profile</a> {% elif service.policy in ("managed", "burst") %} -<a href="{{ utils.url_for(".create_profile") }}" class="btn btn-warning">Request profile</a> +<a href="{{ utils.url_for(".create_profile", intent=True) }}" class="btn btn-warning">Request profile</a> {% else %} -<a href="#" class="btn btn-primary" disabled>Create profile</a> +<a href="#" class="btn btn-success" disabled>Create profile</a> {% endif %} {% endblock %} diff --git a/hiboo/models.py b/hiboo/models.py index 8acbadecc1eeeea9b486a053be29af95d7fa9716..0a6481dc9dc1e37e3ac2037d7deddf37892b84ef 100644 --- a/hiboo/models.py +++ b/hiboo/models.py @@ -10,7 +10,7 @@ import json import uuid -def log(category, value=None, comment=None, user=None, profile=None, service=None, actor=None): +def log(category, value=None, comment=None, user=None, identity=None, service=None, actor=None): """ Log a history event """ event = History() @@ -18,7 +18,7 @@ def log(category, value=None, comment=None, user=None, profile=None, service=Non event.value = value event.comment = comment event.user = user - event.profile = profile + event.identity = identity event.service = service event.actor = actor db.session.add(event) @@ -164,10 +164,11 @@ class Service(db.Model): config = db.Column(JSONEncoded) -class Profile(db.Model): - """ A profile is a user instance for a given service. +class Identity(db.Model): + """ An identity is either a profile or an avatar for a user and given + service. """ - __tablename__ = "profile" + __tablename__ = "identity" UNCLAIMED = "unclaimed" REQUEST = "request" @@ -184,6 +185,7 @@ class Profile(db.Model): username = db.Column(db.String(255), nullable=False) status = db.Column(db.String(25), nullable=False) + extra = db.Column(JSONEncoded) @property def email(self): @@ -198,7 +200,7 @@ class Profile(db.Model): class History(db.Model): - """ Records an even in an account's or profile's lifetime. + """ Records an even in an account's or identity's lifetime. """ __tablename__ = "history" @@ -211,17 +213,17 @@ class History(db.Model): DESCRIPTION = { SIGNUP: _("signed up for this account"), - CREATE: _("created the profile {this.profile.username} on {this.service.name}"), + CREATE: _("created the identity {this.identity.username} on {this.service.name}"), PASSWORD: _("changed your password") } user_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid)) - profile_uuid = db.Column(db.String(36), db.ForeignKey(Profile.uuid)) + identity_uuid = db.Column(db.String(36), db.ForeignKey(Identity.uuid)) service_uuid = db.Column(db.String(36), db.ForeignKey(Service.uuid)) actor_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid)) user = db.relationship(User, foreign_keys=[user_uuid], backref=db.backref('history', cascade='all, delete-orphan')) - profile = db.relationship(Profile, + identity = db.relationship(Identity, backref=db.backref('history', cascade='all, delete-orphan')) service = db.relationship(Service, backref=db.backref('history', cascade='all, delete-orphan')) diff --git a/hiboo/security.py b/hiboo/security.py index b7bd1d29b6e80986a54aff8e05dff083bd598f1a..6ee5ea39a4d49e7bbfc639080ddb3f320429dcd6 100644 --- a/hiboo/security.py +++ b/hiboo/security.py @@ -1,6 +1,9 @@ import flask_login +import flask_wtf import flask import functools +import flask_babel +import wtforms def permissions_wrapper(handler): @@ -39,3 +42,23 @@ def authentication_required(args, kwargs): """ The view is only available to logged in users. """ return True + + +class ConfirmationForm(flask_wtf.FlaskForm): + submit = wtforms.fields.SubmitField(flask_babel.lazy_gettext('Confirm')) + + +def confirmation_required(action): + """ The view is only available after manual confirmation. + """ + def inner(decorated): + @functools.wraps(decorated) + def wrapper(*args, **kwargs): + form = ConfirmationForm() + if form.validate_on_submit(): + return decorated(*args, **kwargs) + return flask.render_template( + "confirm.html", action=action.format(*args, **kwargs), form=form + ) + return wrapper + return inner diff --git a/hiboo/service/__init__.py b/hiboo/service/__init__.py index 148372807beeeb2193a49aeca4eb5d06c1364fd6..6332441f0e7b2d958b488b7c99ca846a294d226c 100644 --- a/hiboo/service/__init__.py +++ b/hiboo/service/__init__.py @@ -1,6 +1,6 @@ -from flask import Blueprint +import flask -blueprint = Blueprint("service", __name__, template_folder="templates") +blueprint = flask.Blueprint("service", __name__, template_folder="templates") from hiboo.service import admin diff --git a/hiboo/service/admin.py b/hiboo/service/admin.py index a47fe15d60e765a720010a054f2c22322f5e86f9..ad7b0b14370507e1f52bdd45a1ce8e7f4ce39581 100644 --- a/hiboo/service/admin.py +++ b/hiboo/service/admin.py @@ -14,14 +14,11 @@ def list(): @blueprint.route("/create") -@security.admin_required() -def create(): - return flask.render_template("service_create.html", protocols=protocols) - - @blueprint.route("/create/<protocol_name>", methods=["GET", "POST"]) @security.admin_required() -def create_protocol(protocol_name): +def create(protocol_name=None): + if protocol_name is None: + return flask.render_template("protocol_pick.html", protocols=protocols) protocol = protocols.get(protocol_name, None) or flask.abort(404) form = protocol.Config.derive_form(forms.ServiceForm)() if form.validate_on_submit(): @@ -35,7 +32,7 @@ def create_protocol(protocol_name): models.db.session.commit() flask.flash("Service successfully created", "success") return flask.redirect(flask.url_for(".list")) - return flask.render_template("service_create_form.html", + return flask.render_template("service_create.html", protocol=protocol, form=form) @@ -44,3 +41,13 @@ def create_protocol(protocol_name): def details(service_uuid): service = models.Service.query.get(service_uuid) or flask.abort(404) return flask.render_template("service_details.html", service=service) + + +@blueprint.route("/delete/<service_uuid>", methods=["GET", "POST"]) +@security.admin_required() +@security.confirmation_required("delete the service") +def delete(service_uuid): + service = models.Service.query.get(service_uuid) or flask.abort(404) + models.db.session.delete(service) + models.db.session.commit() + return flask.redirect(flask.url_for(".list")) diff --git a/hiboo/service/templates/protocol_pick.html b/hiboo/service/templates/protocol_pick.html new file mode 100644 index 0000000000000000000000000000000000000000..49b9632b471b8d9aaeef3052ce26fc02567bbe4d --- /dev/null +++ b/hiboo/service/templates/protocol_pick.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Create a service{% endtrans %}{% endblock %} +{% block subtitle %}{% trans %}pick a protocol{% endtrans %}{% endblock %} + +{% set colors = ['blue', 'green', 'orange', 'teal', 'red', 'purple', 'maroon'] %} + +{% block content %} +<div class="row"> +{% for protocol in protocols %} +{% import "protocol_" + protocol + ".html" as protocol_macros %} +<div class="col-md-4 col-s-6 col-xs-12"> + <div class="box box-widget widget-user-2"> + <div class="widget-user-header bg-{{ colors[loop.index0 % 7] }}"> + <a href="{{ url_for(".create", protocol_name=protocol) }}" style="opacity: 0.8" class="btn btn-lg bg-gray text-black pull-right"> + {% trans %}Create {% endtrans %}{{ protocol_macros.name() }} + </a> + <h3 class="widget-header-username">{{ protocol_macros.name() }}</h3> + <h5 class="widget-header-desc">{{ protocol_macros.description() }} </h5> + </div> + </div> +</div> +{% endfor %} +</div> +{% endblock %} diff --git a/hiboo/service/templates/service_create.html b/hiboo/service/templates/service_create.html index 7e68d86dbe51a1c8240fa55cf833e7f32b9f5142..40db580b03391a421f5bf2a364983166ec0bb813 100644 --- a/hiboo/service/templates/service_create.html +++ b/hiboo/service/templates/service_create.html @@ -1,25 +1,4 @@ -{% extends "base.html" %} +{% extends "form.html" %} {% block title %}{% trans %}Create a service{% endtrans %}{% endblock %} -{% block subtitle %}{% trans %}pick a protocol{% endtrans %}{% endblock %} - -{% set colors = ['blue', 'green', 'orange', 'teal', 'red', 'purple', 'maroon'] %} - -{% block content %} -<div class="row"> -{% for protocol in protocols %} -{% import "protocol_" + protocol + ".html" as protocol_macros %} -<div class="col-md-4 col-s-6 col-xs-12"> - <div class="box box-widget widget-user-2"> - <div class="widget-user-header bg-{{ colors[loop.index0 % 7] }}"> - <a href="{{ url_for(".create_protocol", protocol_name=protocol) }}" style="opacity: 0.8" class="btn btn-lg bg-gray text-black pull-right"> - {% trans %}Create {% endtrans %}{{ protocol_macros.name() }} - </a> - <h3 class="widget-header-username">{{ protocol_macros.name() }}</h3> - <h5 class="widget-header-desc">{{ protocol_macros.description() }} </h5> - </div> - </div> -</div> -{% endfor %} -</div> -{% endblock %} +{% block subtitle %}{% trans %}add a SAML service{% endtrans %}{% endblock %} diff --git a/hiboo/service/templates/service_create_form.html b/hiboo/service/templates/service_create_form.html deleted file mode 100644 index 40db580b03391a421f5bf2a364983166ec0bb813..0000000000000000000000000000000000000000 --- a/hiboo/service/templates/service_create_form.html +++ /dev/null @@ -1,4 +0,0 @@ -{% extends "form.html" %} - -{% block title %}{% trans %}Create a service{% endtrans %}{% endblock %} -{% block subtitle %}{% trans %}add a SAML service{% endtrans %}{% endblock %} diff --git a/hiboo/service/templates/service_details.html b/hiboo/service/templates/service_details.html index 8ccae60a81ab7fa0d52eb97e3182556810746692..e690ad0682f3c66617be94e466a89f7505467f1c 100644 --- a/hiboo/service/templates/service_details.html +++ b/hiboo/service/templates/service_details.html @@ -28,6 +28,7 @@ {% endblock %} {% block actions %} +<a href="{{ url_for("identity.list_profiles", service_uuid=service.uuid) }}" class="btn btn-primary">{% trans %}View profiles{% endtrans %}</a> <a href="{{ url_for(".create") }}" class="btn btn-primary">{% trans %}Edit this service{% endtrans %}</a> -<a href="{{ url_for(".create") }}" class="btn btn-danger">{% trans %}Delete this service{% endtrans %}</a> +<a href="{{ url_for(".delete", service_uuid=service.uuid) }}" class="btn btn-danger">{% trans %}Delete this service{% endtrans %}</a> {% endblock %} diff --git a/hiboo/service/templates/service_list.html b/hiboo/service/templates/service_list.html index d1f13db4e0bc297b977d081f0a2f460792818812..7917d32dc3d8f7d7e8e8d52e41b6e14ff5b36631 100644 --- a/hiboo/service/templates/service_list.html +++ b/hiboo/service/templates/service_list.html @@ -31,5 +31,5 @@ {% endblock %} {% block actions %} -<a href="{{ url_for(".create") }}" class="btn btn-primary">{% trans %}Create a service{% endtrans %}</a> +<a href="{{ url_for(".create") }}" class="btn btn-success">{% trans %}Create a service{% endtrans %}</a> {% endblock %} diff --git a/hiboo/sso/__init__.py b/hiboo/sso/__init__.py index a845b1ff61518710fba0afacd7245b6ed9fd7a31..504ae9128498376348dfd4d28738825113730714 100644 --- a/hiboo/sso/__init__.py +++ b/hiboo/sso/__init__.py @@ -1,7 +1,7 @@ -from flask import Blueprint +import flask -blueprint = Blueprint("sso", __name__, template_folder="templates") +blueprint = flask.Blueprint("sso", __name__, template_folder="templates") from hiboo.sso import saml, oidc diff --git a/hiboo/sso/saml.py b/hiboo/sso/saml.py index 332a39b0dd3a67330079998573c2f2b098a5b2d1..c62b6cb61cd078445fe5b931bb715de2cef8b7ff 100644 --- a/hiboo/sso/saml.py +++ b/hiboo/sso/saml.py @@ -6,7 +6,7 @@ from saml2 import sigver sigver.security_context = security_context from hiboo.sso import blueprint, forms -from hiboo import models, utils, account, security +from hiboo import models, utils, identity, security from saml2 import server, saml, config, mdstore, assertion from cryptography import x509 from cryptography.hazmat import primitives, backends @@ -22,7 +22,7 @@ class Config(object): def derive_form(cls, form): """ Add required fields to a form. """ - return type('NewForm', (form, forms.SAMLForm), {}) + return type('NewForm', (forms.SAMLForm, form), {}) @classmethod def populate_service(cls, form, service): @@ -159,9 +159,7 @@ def saml_redirect(service_uuid): # Get the profile from user input (implies redirects) service = models.Service.query.get(service_uuid) or flask.abort(404) service.protocol == "saml" or flask.abort(404) - profile = account.profiles.get_profile( - service, intent="sso.saml_redirect", service_uuid=service_uuid - ) or flask.abort(403) + profile = identity.get_identity(service, intent=True) or flask.abort(403) # Parse the authentication request and check the ACS idp = server.Server(config=(MetaData.get_config(service))) xml = flask.request.args["SAMLRequest"] diff --git a/hiboo/templates/confirm.html b/hiboo/templates/confirm.html new file mode 100644 index 0000000000000000000000000000000000000000..61d3a3872b43b50841679b9b5aac542987a4116f --- /dev/null +++ b/hiboo/templates/confirm.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Confirm your action{% endtrans %}{% endblock %} +{% block subtitle %}{{ action }}{% endblock %} + +{% block content %} +<p> + {% trans action %}Your are about to {{ action }}. Do you wish to confirm that action?{% endtrans %} +</p> +{{ macros.form(form) }} +{% endblock %} diff --git a/hiboo/user/__init__.py b/hiboo/user/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8d4e63e11834e0c6c1b517d50e3d5b367c7e7f7e --- /dev/null +++ b/hiboo/user/__init__.py @@ -0,0 +1,14 @@ +import flask + + +blueprint = flask.Blueprint("user", __name__, template_folder="templates") + +from hiboo import models, utils +from hiboo.user import users + + +def get_user(**redirect_args): + user_uuid = flask.request.args.get("user_uuid") + if user_uuid: + return models.User.query.get(user_uuid) or None + utils.force_redirect(utils.url_for("user.pick_user", **redirect_args)) diff --git a/hiboo/user/forms.py b/hiboo/user/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..eccf3fc665912ce79c87ef12bc73cc51f19ecdd7 --- /dev/null +++ b/hiboo/user/forms.py @@ -0,0 +1,8 @@ +from wtforms import validators, fields +from flask_babel import lazy_gettext as _ + +import flask_wtf + + +class UserPickForm(flask_wtf.FlaskForm): + user_uuid = fields.TextField('user', []) diff --git a/hiboo/user/templates/user_pick.html b/hiboo/user/templates/user_pick.html new file mode 100644 index 0000000000000000000000000000000000000000..c2a52dfeddce2f2e38a44888a3fe1c7cabe323ae --- /dev/null +++ b/hiboo/user/templates/user_pick.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Pick a user{% endtrans %}{% endblock %} + +{% block content %} +<div class="row"> + <div class="col-xs-12"> + <div class="box"> + <div class="box-body table-responsive no-padding"> + <table class="table table-hover"> + <tr> + <th>{% trans %}Username{% endtrans %}</th> + <th>{% trans %}Created on{% endtrans %}</th> + </tr> + {% for user in users %} + <tr> + <td><a href="{{ utils.url_or_intent("account.home", user_uuid=user.uuid) }}">{{ user.username }}</a></td> + <td>{{ user.created_at.date() }}</td> + </tr> + {% endfor %} + </table> + </div> + </div> + </div> +</div> +{% endblock %} diff --git a/hiboo/user/users.py b/hiboo/user/users.py new file mode 100644 index 0000000000000000000000000000000000000000..9121666d8f058665cb115fee0b37c8cf2459f576 --- /dev/null +++ b/hiboo/user/users.py @@ -0,0 +1,12 @@ +from hiboo.user import blueprint, forms +from hiboo import models, utils, security + +import flask + + +@blueprint.route("/user/pick") +@security.admin_required() +def pick_user(): + users = models.User.query.all() + form = forms.UserPickForm() + return flask.render_template("user_pick.html", users=users, form=form) diff --git a/hiboo/utils.py b/hiboo/utils.py index 56afd08803195cd1993bf5f8169c91e1554de656..d5d319fd278d41245b69349c0ee9d7bbbfa5c366 100644 --- a/hiboo/utils.py +++ b/hiboo/utils.py @@ -27,25 +27,31 @@ def url_for(endpoint, intent=None, *args, **kwargs): an intent """ query_string = dict(flask.request.args) + query_string.update(dict(flask.request.view_args or {})) for key, value in kwargs.items(): if value is None and key in query_string: del query_string[key] elif value is not None: query_string[key] = value - if INTENTS in query_string and intent is not None: - query_string[INTENTS] += ":" + intent - elif INTENTS not in query_string and intent is not None: - query_string[INTENTS] = intent + if intent is not None: + if intent == True: + intent = flask.request.endpoint + if INTENTS in query_string: + query_string[INTENTS] += ":" + intent + else: + query_string[INTENTS] = intent return flask.url_for(endpoint, *args, **query_string) -def url_or_intent(endpoint): +def url_or_intent(endpoint, **kwargs): """ Return the latest intent, or the endpoint url if none """ intents = flask.request.args.get(INTENTS, "") if intents: intents = intents.split(":") - return url_for(intents.pop(), intents=":".join(intents) or None) + return url_for( + intents.pop(), intents=":".join(intents) or None, **kwargs + ) else: return flask.url_for(endpoint) @@ -53,7 +59,9 @@ def url_or_intent(endpoint): def force_redirect(destination): """ Force a redirect by triggering an exception """ - raise routing.RequestRedirect(destination) + redirect = routing.RequestRedirect(destination) + redirect.code = 302 + raise redirect def display_help(identifier): diff --git a/migrations/versions/cfb466a78348_.py b/migrations/versions/cfb466a78348_.py index 2c1cfad2e29d5c5c0f86bd4f4adf58abf346c078..129c0f12e82d3233fa5a342290b704f69246e2e9 100644 --- a/migrations/versions/cfb466a78348_.py +++ b/migrations/versions/cfb466a78348_.py @@ -51,11 +51,12 @@ def upgrade(): sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'], ), sa.PrimaryKeyConstraint('uuid') ) - op.create_table('profile', + op.create_table('identity', sa.Column('user_uuid', sa.String(length=36), nullable=True), sa.Column('service_uuid', sa.String(length=36), nullable=True), sa.Column('username', sa.String(length=255), nullable=False), sa.Column('status', sa.String(length=25), nullable=False), + sa.Column('extra', sa.String(), nullable=True), sa.Column('uuid', sa.String(length=36), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=True), @@ -66,7 +67,7 @@ def upgrade(): ) op.create_table('history', sa.Column('user_uuid', sa.String(length=36), nullable=True), - sa.Column('profile_uuid', sa.String(length=36), nullable=True), + sa.Column('identity_uuid', sa.String(length=36), nullable=True), sa.Column('service_uuid', sa.String(length=36), nullable=True), sa.Column('actor_uuid', sa.String(length=36), nullable=True), sa.Column('category', sa.String(length=25), nullable=True), @@ -76,7 +77,7 @@ def upgrade(): sa.Column('updated_at', sa.DateTime(), nullable=True), sa.Column('comment', sa.String(length=255), nullable=True), sa.ForeignKeyConstraint(['actor_uuid'], ['user.uuid'], ), - sa.ForeignKeyConstraint(['profile_uuid'], ['profile.uuid'], ), + sa.ForeignKeyConstraint(['identity_uuid'], ['identity.uuid'], ), sa.ForeignKeyConstraint(['service_uuid'], ['service.uuid'], ), sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'], ), sa.PrimaryKeyConstraint('uuid') @@ -85,7 +86,7 @@ def upgrade(): def downgrade(): op.drop_table('history') - op.drop_table('profile') + op.drop_table('identity') op.drop_table('auth') op.drop_table('user') op.drop_table('service')