update fixes, ...

This commit is contained in:
TuxCoder 2022-07-15 10:53:06 +02:00
parent c6042973fe
commit 1947a6f24a
31 changed files with 3395 additions and 145 deletions

View file

@ -82,7 +82,7 @@ window.auth = {
submit: function(form) { submit: function(form) {
SimpleFormSubmit.submitForm(form.action, form) SimpleFormSubmit.submitForm(form.action, form)
.then(response =>{ .then(response =>{
response.json().then(function(data) { response.json().then(data => {
if (data.errors) { if (data.errors) {
var msg ='<ul>'; var msg ='<ul>';
for( var field in data.errors) { for( var field in data.errors) {
@ -156,7 +156,9 @@ window.password_change= {
new Dialog('Password changed', 'Password changed successfully!').show(); new Dialog('Password changed', 'Password changed successfully!').show();
} }
}); });
}); }).error(error =>{
new Dialog('Password change Error', `Error Happend: ${msg}`).show()
});
return false; return false;
} }
@ -201,10 +203,10 @@ window.client_cert = {
SimpleFormSubmit.submitForm(form_sign_key.action, form_sign_key) SimpleFormSubmit.submitForm(form_sign_key.action, form_sign_key)
.then(response => { .then(response => {
response.json().then( response => { response.json().then( json_data => {
if (data.errors) { if (json_data.errors) {
var msg ='<ul>'; var msg ='<ul>';
for( var field in data.errors) { for( var field in json_data.repsonse) {
msg += `<li>${field}: ${data.errors[field]}</li>`; msg += `<li>${field}: ${data.errors[field]}</li>`;
} }
msg += '</ul>'; msg += '</ul>';

194
index.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -36,12 +36,11 @@ LENTICULAR_CLOUD_SERVICES = {
'client_cert': True, 'client_cert': True,
'pki_config':{ 'pki_config':{
'email': '{username}@jabber.{domain}' 'email': '{username}@jabber.{domain}'
} },
'app_token': True
}, },
'calendar': { 'mail-cardav': {
'client_cert': True 'client_cert': False,
}, 'app_token': True
'mail': {
'client_cert': True
} }
} }

View file

@ -81,7 +81,7 @@ def cli_run(app: Flask, args) -> None:
print("running in debug mode") print("running in debug mode")
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
#app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1) #app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1)
app.run(debug=False, host='127.0.0.1', port=5000) app.run(debug=True, host='127.0.0.1', port=5000)
def cli_db_upgrade(args) -> None: def cli_db_upgrade(args) -> None:

View file

@ -33,6 +33,12 @@ class TOTPForm(FlaskForm):
class TOTPDeleteForm(FlaskForm): class TOTPDeleteForm(FlaskForm):
submit = SubmitField(gettext('Delete')) submit = SubmitField(gettext('Delete'))
class AppTokenForm(FlaskForm):
name = StringField(gettext('name'), validators=[DataRequired(),Length(min=1, max=255) ])
submit = SubmitField(gettext('Activate'))
class AppTokenDeleteForm(FlaskForm):
submit = SubmitField(gettext('Delete'))
class WebauthnRegisterForm(FlaskForm): class WebauthnRegisterForm(FlaskForm):
"""webauthn register token form""" """webauthn register token form"""

View file

@ -0,0 +1,48 @@
"""fix app token
Revision ID: 0f217e90cd07
Revises: 0518a8625b50
Create Date: 2022-06-18 23:24:12.687324
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0f217e90cd07'
down_revision = '0518a8625b50'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('app_token', sa.Column('user_id', sa.String(length=36), nullable=False))
op.add_column('app_token', sa.Column('last_used', sa.DateTime(), nullable=True))
op.create_foreign_key(None, 'app_token', 'user', ['user_id'], ['id'])
op.add_column('totp', sa.Column('last_used', sa.DateTime(), nullable=True))
tmp_table = sa.Table('_alembic_tmp_user', sa.MetaData())
op.execute(sa.schema.DropTable(tmp_table, if_exists=True))
with op.batch_alter_table('user') as batch_op:
batch_op.alter_column('enabled',
existing_type=sa.BOOLEAN(),
nullable=False,
existing_server_default=sa.text("'false'"))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
tmp_table = sa.Table('_alembic_tmp_user', sa.MetaData())
op.execute(sa.schema.DropTable(tmp_table, if_exists=True))
with op.batch_alter_table('user') as batch_op:
batch_op.alter_column('enabled',
existing_type=sa.BOOLEAN(),
nullable=True,
existing_server_default=sa.text("'false'"))
op.drop_column('totp', 'last_used')
op.drop_constraint(None, 'app_token', type_='foreignkey')
op.drop_column('app_token', 'last_used')
op.drop_column('app_token', 'user_id')
# ### end Alembic commands ###

View file

@ -9,6 +9,8 @@ import pyotp
import json import json
import logging import logging
import crypt import crypt
import secrets
import string
from flask_sqlalchemy import SQLAlchemy, orm from flask_sqlalchemy import SQLAlchemy, orm
from flask_migrate import Migrate from flask_migrate import Migrate
from datetime import datetime from datetime import datetime
@ -23,7 +25,7 @@ logger = logging.getLogger(__name__)
db = SQLAlchemy() # type: SQLAlchemy db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
@ -42,6 +44,7 @@ class Service(object):
def __init__(self, name: str): def __init__(self, name: str):
self._name = name self._name = name
self._app_token = False
self._client_cert = False self._client_cert = False
self._pki_config = { self._pki_config = {
'cn': '{username}', 'cn': '{username}',
@ -53,6 +56,8 @@ class Service(object):
""" """
""" """
service = Service(name) service = Service(name)
if 'app_token' in config:
service._app_token = bool(config['app_token'])
if 'client_cert' in config: if 'client_cert' in config:
service._client_cert = bool(config['client_cert']) service._client_cert = bool(config['client_cert'])
if 'pki_config' in config: if 'pki_config' in config:
@ -68,6 +73,10 @@ class Service(object):
def client_cert(self) -> bool: def client_cert(self) -> bool:
return self._client_cert return self._client_cert
@property
def app_token(self) -> bool:
return self._app_token
@property @property
def pki_config(self) -> dict[str,str]: def pki_config(self) -> dict[str,str]:
if not self._client_cert: if not self._client_cert:
@ -148,6 +157,7 @@ class User(BaseModel):
enabled = db.Column(db.Boolean, nullable=False, default=False) enabled = db.Column(db.Boolean, nullable=False, default=False)
app_tokens = db.relationship('AppToken', back_populates='user')
totps = db.relationship('Totp', back_populates='user') totps = db.relationship('Totp', back_populates='user')
webauthn_credentials = db.relationship('WebauthnCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True) webauthn_credentials = db.relationship('WebauthnCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True)
@ -162,7 +172,7 @@ class User(BaseModel):
print(f'getitem: {key}') # TODO print(f'getitem: {key}') # TODO
@property @property
def groups(self) -> list[str]: def groups(self) -> list['Group']:
if self.username == 'tuxcoder': if self.username == 'tuxcoder':
return [Group(name='admin')] return [Group(name='admin')]
else: else:
@ -173,23 +183,44 @@ class User(BaseModel):
domain = current_app.config['DOMAIN'] domain = current_app.config['DOMAIN']
return f'{self.username}@{domain}' return f'{self.username}@{domain}'
def change_password(self, password_new: str) -> bool: def change_password(self, password_new: str) -> None:
password_hashed = crypt.crypt(password_new) self.password_hashed = crypt.crypt(password_new)
return True
def get_tokens_by_service(self, service: Service) -> list['AppToken']:
return [ token for token in self.app_tokens if token.service_name == service.name ]
def get_token(self, service: Service, name: str) -> Optional['AppToken']:
for token in self.app_tokens:
if token.service_name == service.name and token.name == name:
return token # type: ignore
return None
class AppToken(BaseModel): class AppToken(BaseModel):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
service_name = db.Column(db.String, nullable=False) service_name = db.Column(db.String, nullable=False)
user_id = db.Column(
db.String(length=36),
db.ForeignKey(User.id), nullable=False)
user = db.relationship(User)
token = db.Column(db.String, nullable=False) token = db.Column(db.String, nullable=False)
name = db.Column(db.String, nullable=False) name = db.Column(db.String, nullable=False)
last_used = db.Column(db.DateTime, nullable=True)
@staticmethod
def new(service: Service):
app_token = AppToken()
app_token.service_name = service.name
alphabet = string.ascii_letters + string.digits
app_token.token = ''.join(secrets.choice(alphabet) for i in range(12))
return app_token
class Totp(BaseModel): class Totp(BaseModel):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
secret = db.Column(db.String, nullable=False) secret = db.Column(db.String, nullable=False)
name = db.Column(db.String, nullable=False) name = db.Column(db.String, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False) created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
#last_used = db.Column(db.DateTime, nullable=True) last_used = db.Column(db.DateTime, nullable=True)
user_id = db.Column( user_id = db.Column(
db.String(length=36), db.String(length=36),

View file

@ -48,7 +48,7 @@ class Pki(object):
''' '''
pki_path: str base path from the pkis pki_path: str base path from the pkis
''' '''
self._pki_path = Path(os.getcwd()) / app.config['PKI_PATH'] self._pki_path = Path(app.root_path) / app.config['PKI_PATH']
self._domain = app.config['DOMAIN'] self._domain = app.config['DOMAIN']
@ -328,4 +328,4 @@ class Pki(object):
backend=default_backend()) backend=default_backend())
return crl return crl
pki = Pki() pki = Pki()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,50 +4,48 @@
{% block content %} {% block content %}
{#
<ul class="nav nav-tabs" id="myTab" role="tablist"> <ul class="nav nav-tabs" id="myTab" role="tablist">
{% for service in services.values() %} {% for service in services.values() if service.app_token %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link{{' active' if loop.first else ''}}" id="home-tab" data-toggle="tab" href="#{{ service.name }}" role="tab" aria-controls="home" aria-selected="true">{{ service.name }}</a> <a class="nav-link{{' active' if loop.first else ''}}" id="home-tab" data-toggle="tab" href="#{{ service.name }}" role="tab" aria-controls="home" aria-selected="true">{{ service.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div class="tab-content" id="myTabContent"> <div class="tab-content" id="myTabContent">
{% for service in services.values() if service.client_cert %} {% for service in services.values() if service.app_token %}
<div class="tab-pane fade{{ ' show active' if loop.first else '' }}" id="{{ service.name }}" role="tabpanel" aria-labelledby="{{ service.name }}-tab"> <div class="tab-pane fade{{ ' show active' if loop.first else '' }}" id="{{ service.name }}" role="tabpanel" aria-labelledby="{{ service.name }}-tab">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>not valid before</th> <th>name</th>
<th>not valid after</th> <th>last used</th>
<th>serial_number<th> <th>created at<th>
<th> <th> <th> <th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for cert in client_certs[service.name] %} {% for app_token in current_user.get_tokens_by_service(service) %}
<tr {{ 'class="table-warning"' if not cert.is_valid else ''}}> <tr>
<td>{{ cert.not_valid_before }}</td> <td>{{ app_token.name }}</td>
<td>{{ cert.not_valid_after }}</td> <td>{{ app_token.last_used }}</td>
<td>{{ cert.serial_number_hex }}</td> <td>{{ app_token.created_at }}</td>
<td> <td>
<a title="{{ gettext('Download') }}" href="{{ url_for('.get_client_cert', service_name=service.name, serial_number=cert.serial_number_hex) }}"><i class="fas fa-file-download"></i></a> {{ render_form(delete_form, action_url=url_for('frontend.app_token_delete', service_name=service.name,app_token_name=app_token.name)) }}
&nbsp; {#
{% if cert.is_valid %} <a title="{{ gettext('Revoke')}}" href="{{ url_for('.app_token_revoce', id=app_token.id) }}" onclick="client_cert.revoke_certificate(this.href, '{{ cert.serial_number_hex }}'); return false;"><i class="fas fa-ban"></i></a>
<a title="{{ gettext('Revoke')}}" href="{{ url_for('.revoke_client_cert', service_name=service.name, serial_number=cert.serial_number_hex) }}" onclick="client_cert.revoke_certificate(this.href, '{{ cert.serial_number_hex }}'); return false;"><i class="fas fa-ban"></i></a> #}
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
<a class="btn btn-primary" href="{{ url_for('frontend.client_cert_new', service_name=service.name) }}"> <a class="btn btn-primary" href="{{ url_for('frontend.app_token_new', service_name=service.name) }}">
New Certificate New Token
</a> </a>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
#}
{% endblock %} {% endblock %}

View file

@ -1,44 +1,12 @@
{% extends 'frontend/base.html.j2' %} {% extends 'frontend/base.html.j2' %}
{% block title %}{{ gettext('new client cert - {service_name}').format(service_name=service.name) }}{% endblock %} {% block title %}{{ gettext('new app token for {service_name}').format(service_name=service.name) }}{% endblock %}
{% block content %} {% block content %}
<div id="sign-key"> <div>
<h4>Sign Public Key</h4>
{{ render_form(form) }} {{ render_form(form) }}
</div> </div>
<div id="gen-key">
<h4>Generate new key in the browser</h4>
<div id="gen-key-sign" style="display: none">
{{ render_form(form) }}
</div>
<form id="gen-key-form">
<div class="form-group">
<label for="valid_time" class="control-label ">Key Password for .p12 (optional)</label>
<div class="">
<input class="form-control" id="cert-password" type="password" name="password"/>
</div>
</div>
<div class="form-group">
<label for="valid_time" class="control-label ">Key Size</label>
<div class="">
<select id="key-size" class="custom-select">
<option value="4096" selected>4096</option>
<option value="2048">2048</option>
</select>
</div>
</div>
<div class="form-group ">
<label for="valid_time" class="control-label ">valid time in days</label>
<div class="">
<input class="form-control" name="valid_time" required type="text" value="365">
</div>
</div>
</form>
<button id="generate-key" class="btn btn-primary" onclick="client_cert.generate_private_key()">Generate Key</button>
<a style="display: none" id="save-button" download="lenticular_cloud_{{ service.name }}.p12" class="btn btn-primary">Save Keypair</a>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,25 @@
{% extends 'frontend/base.html.j2' %}
{% block title %}{{ gettext('new app token for {service_name}').format(service_name=service.name) }}{% endblock %}
{% block content %}
<div>
<p>
Your new App Token for {{ service.name }}:
</p>
<p>
<code id="app_token_secret">{{ app_token.token }}</code><br />
</p>
<p>
<button class="btn btn-primary" onclick="navigator.clipboard.writeText(document.getElementById('app_token_secret').textContent);">
<i class="fa-solid fa-clipboard"></i>
Copy Secret
</button>
</p>
</div>
{% endblock %}

View file

@ -6,7 +6,7 @@
<ul class="nav nav-tabs" id="myTab" role="tablist"> <ul class="nav nav-tabs" id="myTab" role="tablist">
{% for service in services.values() %} {% for service in services.values() if service.client_cert %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link{{' active' if loop.first else ''}}" id="home-tab" data-toggle="tab" href="#{{ service.name }}" role="tab" aria-controls="home" aria-selected="true">{{ service.name }}</a> <a class="nav-link{{' active' if loop.first else ''}}" id="home-tab" data-toggle="tab" href="#{{ service.name }}" role="tab" aria-controls="home" aria-selected="true">{{ service.name }}</a>
</li> </li>

View file

@ -9,6 +9,7 @@ from authlib.integrations.base_client.errors import InvalidTokenError
from ory_hydra_client.api.admin import list_o_auth_2_clients, get_o_auth_2_client, update_o_auth_2_client, create_o_auth_2_client from ory_hydra_client.api.admin import list_o_auth_2_clients, get_o_auth_2_client, update_o_auth_2_client, create_o_auth_2_client
from ory_hydra_client.models import OAuth2Client, GenericError from ory_hydra_client.models import OAuth2Client, GenericError
from typing import Optional from typing import Optional
from collections.abc import Iterable
import logging import logging
from ..model import db, User from ..model import db, User
@ -44,19 +45,19 @@ async def index() -> ResponseReturnValue:
@admin_views.route('/user', methods=['GET']) @admin_views.route('/user', methods=['GET'])
async def users(): async def users():
users = User.query.all() users = User.query.all() # type: Iterable[User]
return render_template('admin/users.html.j2', users=users) return render_template('admin/users.html.j2', users=users)
@admin_views.route('/registrations', methods=['GET']) @admin_views.route('/registrations', methods=['GET'])
def registrations() -> ResponseReturnValue: def registrations() -> ResponseReturnValue:
users = User.query.filter_by(enabled=False).all() users = User.query.filter_by(enabled=False).all() # type: Iterable[User]
return render_template('admin/registrations.html.j2', users=users) return render_template('admin/registrations.html.j2', users=users)
@admin_views.route('/registration/<registration_id>', methods=['DELETE']) @admin_views.route('/registration/<registration_id>', methods=['DELETE'])
def registration_delete(registration_id) -> ResponseReturnValue: def registration_delete(registration_id) -> ResponseReturnValue:
user = User.query.get(registration_id) user = User.query.get(registration_id) # type: Optional[User]
if user is None: if user is None:
return jsonify({}), 404 return jsonify({}), 404
db.session.delete(user) db.session.delete(user)
@ -66,7 +67,9 @@ def registration_delete(registration_id) -> ResponseReturnValue:
@admin_views.route('/registration/<registration_id>', methods=['PUT']) @admin_views.route('/registration/<registration_id>', methods=['PUT'])
def registration_accept(registration_id) -> ResponseReturnValue: def registration_accept(registration_id) -> ResponseReturnValue:
user = User.query.get(registration_id) user = User.query.get(registration_id) # type: Optional[User]
if user is None:
return jsonify({'message':'user not found'}), 404
user.enabled = True user.enabled = True
db.session.commit() db.session.commit()
return jsonify({}) return jsonify({})

View file

@ -7,11 +7,15 @@ from flask.templating import render_template
from flask.typing import ResponseReturnValue from flask.typing import ResponseReturnValue
from flask import Blueprint, render_template, request, url_for from flask import Blueprint, render_template, request, url_for
from datetime import datetime
from typing import Optional, Any
import logging import logging
import httpx import httpx
import secrets
from ..model import User from ..model import db, User
from ..hydra import hydra_service from ..hydra import hydra_service
from ..lenticular_services import lenticular_services
from ory_hydra_client.api.admin import introspect_o_auth_2_token from ory_hydra_client.api.admin import introspect_o_auth_2_token
from ory_hydra_client.models import GenericError from ory_hydra_client.models import GenericError
@ -40,39 +44,50 @@ def user_list() -> ResponseReturnValue:
@api_views.route('/introspect', methods=['POST']) @api_views.route('/introspect', methods=['POST'])
def introspect() -> ResponseReturnValue: def introspect() -> ResponseReturnValue:
token = request.form['token'] token = request.form['token'] # type: Optional[str]
logger.error(f'debug token: {token}')
resp = httpx.post("https://hydra.cloud.tux.ac/oauth2/introspect", data={'token':token}) resp = httpx.post("https://hydra.cloud.tux.ac/oauth2/introspect", data={'token':token})
#if token_info is None or isinstance(token_info, GenericError):
if resp.status_code != 200: if resp.status_code != 200:
return jsonify({}), 500 return jsonify({}), 500
token_info = resp.json() token_info = resp.json()
#token_info = introspect_o_auth_2_token.sync(_client=hydra_service, token=token)
if not token_info['active']: if not token_info['active']:
return jsonify({'active': False}) return jsonify({'active': False})
token_info['email'] = token_info['ext']['email'] token_info['email'] = token_info['ext']['email']
logger.error(f'debug: {token_info}')
return jsonify(token_info) return jsonify(token_info)
@api_views.route('email/login', methods=['POST']) @api_views.route('/login/<service_name>', methods=['POST'])
def email_login() -> ResponseReturnValue: def email_login(service_name: str) -> ResponseReturnValue:
logger.error(f'{request}') if service_name not in lenticular_services:
logger.error(f'{request.headers}') return '', 404
service = lenticular_services[service_name]
if not request.is_json: if not request.is_json:
return jsonify({}), 400 return jsonify({}), 400
req_payload = request.get_json() req_payload = request.get_json() # type: Any
logger.debug(f'{req_payload}')
if not isinstance(req_payload, dict): if not isinstance(req_payload, dict):
return 'bad request', 400 return 'bad request', 400
password = req_payload["password"] password = req_payload["password"]
username = req_payload["username"] username = req_payload["username"]
if password == "123456": if '@' in username:
return jsonify({}) username = username.split('@')[0]
user = User.query.filter_by(username=username.lower()).first() # type: Optional[User]
if user is None:
logger.warning(f'login with invalid username')
return jsonify({}), 403
for app_token in user.get_tokens_by_service(service):
if secrets.compare_digest(password, app_token.token):
app_token.last_used = datetime.now()
db.session.commit()
return jsonify({'username': user.username}), 200
logger.warning(f'login with invalid password for {username}')
return jsonify({}), 403 return jsonify({}), 403

View file

@ -60,7 +60,9 @@ async def consent() -> ResponseReturnValue:
requested_audiences = consent_request.requested_access_token_audience requested_audiences = consent_request.requested_access_token_audience
if form.validate_on_submit() or consent_request.skip: if form.validate_on_submit() or consent_request.skip:
user = User.query.get(consent_request.subject) user = User.query.get(consent_request.subject) # type: Optional[User]
if user is None:
return 'internal error', 500
token_data = { token_data = {
'name': str(user.username), 'name': str(user.username),
'preferred_username': str(user.username), 'preferred_username': str(user.username),
@ -118,7 +120,7 @@ async def login() -> ResponseReturnValue:
return redirect(resp.redirect_to) return redirect(resp.redirect_to)
form = LoginForm() form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(username=form.data['name']).first() user = User.query.filter_by(username=form.data['name']).first() # type: Optional[User]
if user: if user:
session['username'] = str(user.username) session['username'] = str(user.username)
else: else:
@ -141,7 +143,7 @@ async def login_auth() -> ResponseReturnValue:
if 'username' not in session: if 'username' not in session:
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
auth_forms = {} auth_forms = {}
user = User.query.filter_by(username=session['username']).first() user = User.query.filter_by(username=session['username']).first() # Optional[User]
for auth_provider in AUTH_PROVIDER_LIST: for auth_provider in AUTH_PROVIDER_LIST:
form = auth_provider.get_form() form = auth_provider.get_form()
if auth_provider.get_name() not in session['auth_providers'] and\ if auth_provider.get_name() not in session['auth_providers'] and\
@ -178,7 +180,7 @@ async def login_auth() -> ResponseReturnValue:
def webauthn_pkcro_route(): def webauthn_pkcro_route():
"""login webauthn pkcro route""" """login webauthn pkcro route"""
user = User.query.filter(User.id == session.get('webauthn_login_user_id')).one_or_none() user = User.query.filter(User.id == session.get('webauthn_login_user_id')).one() #type: User
form = ButtonForm() form = ButtonForm()
if user and form.validate_on_submit(): if user and form.validate_on_submit():
pkcro, state = webauthn.authenticate_begin(webauthn_credentials(user)) pkcro, state = webauthn.authenticate_begin(webauthn_credentials(user))

View file

@ -21,11 +21,13 @@ from ory_hydra_client.models import GenericError
from urllib.parse import urlencode, parse_qs from urllib.parse import urlencode, parse_qs
from random import SystemRandom from random import SystemRandom
import string import string
from collections.abc import Iterable
from typing import Optional from typing import Optional
from ..model import db, User, SecurityUser, Totp, WebauthnCredential from ..model import db, User, SecurityUser, Totp, AppToken, WebauthnCredential
from ..form.frontend import ClientCertForm, TOTPForm, \ from ..form.frontend import ClientCertForm, TOTPForm, \
TOTPDeleteForm, PasswordChangeForm, WebauthnRegisterForm TOTPDeleteForm, PasswordChangeForm, WebauthnRegisterForm, \
AppTokenForm, AppTokenDeleteForm
from ..form.base import ButtonForm from ..form.base import ButtonForm
from ..auth_providers import PasswordAuthProvider from ..auth_providers import PasswordAuthProvider
from .auth import webauthn from .auth import webauthn
@ -111,6 +113,8 @@ def revoke_client_cert(service_name, serial_number) -> ResponseReturnValue:
'/client_cert/<service_name>/new', '/client_cert/<service_name>/new',
methods=['GET', 'POST']) methods=['GET', 'POST'])
def client_cert_new(service_name) -> ResponseReturnValue: def client_cert_new(service_name) -> ResponseReturnValue:
if service_name not in lenticular_services:
return '', 404
service = lenticular_services[service_name] service = lenticular_services[service_name]
form = ClientCertForm() form = ClientCertForm()
if form.validate_on_submit(): if form.validate_on_submit():
@ -139,16 +143,51 @@ def client_cert_new(service_name) -> ResponseReturnValue:
@frontend_views.route('/app_token') @frontend_views.route('/app_token')
def app_token() -> ResponseReturnValue: def app_token() -> ResponseReturnValue:
delete_form = TOTPDeleteForm() delete_form = AppTokenDeleteForm()
return render_template('frontend/app_token.html.j2', delete_form=delete_form) form = ClientCertForm()
return render_template('frontend/app_token.html.j2',
delete_form=delete_form,
services=lenticular_services)
@frontend_views.route('/app_token/<service_name>/new') @frontend_views.route('/app_token/<service_name>/new', methods=['GET','POST'])
def app_token_new(service_name: str) -> ResponseReturnValue: def app_token_new(service_name: str) -> ResponseReturnValue:
return if service_name not in lenticular_services:
return '', 404
service = lenticular_services[service_name]
form = AppTokenForm()
@frontend_views.route('/app_token/<service_name>/<token_name>') if form.validate_on_submit():
def app_token_delete(service_name: str, token_name: str) -> ResponseReturnValue: app_token = AppToken.new(service)
return form.populate_obj(app_token)
# check for duplicate names
for user_app_token in current_user.app_tokens:
if user_app_token.name == app_token.name:
return 'name already exist', 400
current_user.app_tokens.append(app_token)
db.session.commit()
return render_template('frontend/app_token_new_show.html.j2', service=service, app_token=app_token)
return render_template('frontend/app_token_new.html.j2',
form=form,
service=service)
@frontend_views.route('/app_token/<service_name>/<app_token_name>', methods=["POST"])
def app_token_delete(service_name: str, app_token_name: str) -> ResponseReturnValue:
form = AppTokenDeleteForm()
if service_name not in lenticular_services:
return '', 404
service = lenticular_services[service_name]
if form.validate_on_submit():
app_token = current_user.get_token(service, app_token_name)
if app_token is None:
return 'not found', 404
db.session.delete(app_token)
db.session.commit()
return redirect(url_for('frontend.app_token'))
@frontend_views.route('/totp') @frontend_views.route('/totp')
def totp() -> ResponseReturnValue: def totp() -> ResponseReturnValue:
@ -178,7 +217,7 @@ def totp_new() -> ResponseReturnValue:
@frontend_views.route('/totp/<totp_name>/delete', methods=['GET', 'POST']) @frontend_views.route('/totp/<totp_name>/delete', methods=['GET', 'POST'])
def totp_delete(totp_name) -> ResponseReturnValue: def totp_delete(totp_name) -> ResponseReturnValue:
totp = Totp.query.filter(Totp.name == totp_name).first() totp = Totp.query.filter(Totp.name == totp_name).first() # type: Optional[Totp]
db.session.delete(totp) db.session.delete(totp)
db.session.commit() db.session.commit()
@ -190,7 +229,7 @@ def totp_delete(totp_name) -> ResponseReturnValue:
def webauthn_list_route() -> ResponseReturnValue: def webauthn_list_route() -> ResponseReturnValue:
"""list registered credentials for current user""" """list registered credentials for current user"""
creds = WebauthnCredential.query.all() creds = WebauthnCredential.query.all() # type: Iterable[WebauthnCredential]
return render_template('frontend/webauthn_list.html', creds=creds, button_form=ButtonForm()) return render_template('frontend/webauthn_list.html', creds=creds, button_form=ButtonForm())
@ -200,7 +239,7 @@ def webauthn_delete_route(webauthn_id: str) -> ResponseReturnValue:
form = ButtonForm() form = ButtonForm()
if form.validate_on_submit(): if form.validate_on_submit():
cred = WebauthnCredential.query.filter(WebauthnCredential.id == webauthn_id).one() cred = WebauthnCredential.query.filter(WebauthnCredential.id == webauthn_id).one() # type: WebauthnCredential
db.session.delete(cred) db.session.delete(cred)
db.session.commit() db.session.commit()
return redirect(url_for('app.webauthn_list_route')) return redirect(url_for('app.webauthn_list_route'))
@ -223,7 +262,9 @@ def random_string(length=32) -> str:
def webauthn_pkcco_route() -> ResponseReturnValue: def webauthn_pkcco_route() -> ResponseReturnValue:
"""get publicKeyCredentialCreationOptions""" """get publicKeyCredentialCreationOptions"""
user = User.query.get(current_user.id) user = User.query.get(current_user.id) #type: Optional[User]
if user is None:
return 'internal error', 500
user_handle = random_string() user_handle = random_string()
exclude_credentials = webauthn_credentials(user) exclude_credentials = webauthn_credentials(user)
pkcco, state = webauthn.register_begin( pkcco, state = webauthn.register_begin(
@ -238,7 +279,7 @@ def webauthn_pkcco_route() -> ResponseReturnValue:
def webauthn_register_route() -> ResponseReturnValue: def webauthn_register_route() -> ResponseReturnValue:
"""register credential for current user""" """register credential for current user"""
user = User.query.get(current_user.id) user = current_user # type: User
form = WebauthnRegisterForm() form = WebauthnRegisterForm()
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
@ -279,11 +320,11 @@ def password_change_post() -> ResponseReturnValue:
current_user, password_old): current_user, password_old):
return jsonify( return jsonify(
{'errors': {'password_old': 'Old Password is invalid'}}) {'errors': {'password_old': 'Old Password is invalid'}})
resp = current_user.change_password(password_new)
if resp: current_user.change_password(password_new)
return jsonify({}) logger.info(f"user {current_user.username} changed password")
else: db.session.commit()
return jsonify({'errors': {'internal': 'internal server errror'}}) return jsonify({})
return jsonify({'errors': form.errors}) return jsonify({'errors': form.errors})

View file

@ -5,6 +5,7 @@ from flask_login import login_user, logout_user, current_user
from flask.typing import ResponseReturnValue from flask.typing import ResponseReturnValue
from flask_login import LoginManager from flask_login import LoginManager
from typing import Optional from typing import Optional
from werkzeug.wrappers.response import Response as WerkzeugResponse
import logging import logging
from ..model import User, SecurityUser from ..model import User, SecurityUser
@ -28,7 +29,7 @@ def redirect_login() -> ResponseReturnValue:
session['next_url'] = request.path session['next_url'] = request.path
redirect_uri = url_for('oauth2.authorized', _external=True) redirect_uri = url_for('oauth2.authorized', _external=True)
response = oauth2.custom.authorize_redirect(redirect_uri) response = oauth2.custom.authorize_redirect(redirect_uri)
if isinstance(response, Response): if not isinstance(response, WerkzeugResponse):
raise RuntimeError("invalid redirect") raise RuntimeError("invalid redirect")
return response return response
@ -44,7 +45,7 @@ def authorized() -> ResponseReturnValue:
return 'bad request', 400 return 'bad request', 400
session['token'] = token session['token'] = token
userinfo = oauth2.custom.get('/userinfo').json() userinfo = oauth2.custom.get('/userinfo').json()
user = User.query.get(str(userinfo["sub"])) user = User.query.get(str(userinfo["sub"])) # type: Optional[User]
if user is None: if user is None:
return "user not found", 404 return "user not found", 404
logger.info(f"user `{user.username}` successfully logged in") logger.info(f"user `{user.username}` successfully logged in")
@ -60,14 +61,14 @@ def authorized() -> ResponseReturnValue:
def login() -> ResponseReturnValue: def login() -> ResponseReturnValue:
redirect_uri = url_for('.authorized', _external=True) redirect_uri = url_for('.authorized', _external=True)
response = oauth2.custom.authorize_redirect(redirect_uri) response = oauth2.custom.authorize_redirect(redirect_uri)
#if type(response) != Response: if not isinstance(response, WerkzeugResponse):
# raise RuntimeError("invalid redirect") raise RuntimeError("invalid redirect")
return response return response
@login_manager.user_loader @login_manager.user_loader
def user_loader(username) -> Optional[User]: def user_loader(username) -> Optional[User]:
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first() # type: Optional[User]
if isinstance(user, User): if isinstance(user, User):
return user return user
else: else:

2953
package-lock.json generated

File diff suppressed because it is too large Load diff