update fixes, ...
This commit is contained in:
parent
c6042973fe
commit
1947a6f24a
|
@ -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,6 +156,8 @@ 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
194
index.html
Normal file
File diff suppressed because one or more lines are too long
|
@ -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}'
|
||||||
}
|
|
||||||
},
|
},
|
||||||
'calendar': {
|
'app_token': True
|
||||||
'client_cert': True
|
|
||||||
},
|
},
|
||||||
'mail': {
|
'mail-cardav': {
|
||||||
'client_cert': True
|
'client_cert': False,
|
||||||
|
'app_token': True
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"""
|
||||||
|
|
|
@ -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 ###
|
|
@ -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),
|
||||||
|
|
|
@ -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']
|
||||||
|
|
||||||
|
|
||||||
|
|
BIN
lenticular_cloud/static/0caf4c6cf244a90efcc5.woff2
Normal file
BIN
lenticular_cloud/static/0caf4c6cf244a90efcc5.woff2
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/0d03b1bbd1d62c1e1284.ttf
Normal file
BIN
lenticular_cloud/static/0d03b1bbd1d62c1e1284.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/4baccb548138840fa33a.ttf
Normal file
BIN
lenticular_cloud/static/4baccb548138840fa33a.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/59edf72a325ac2048d60.woff2
Normal file
BIN
lenticular_cloud/static/59edf72a325ac2048d60.woff2
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/afac89562a5301459069.woff2
Normal file
BIN
lenticular_cloud/static/afac89562a5301459069.woff2
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/b823fc0dbb5a5f0c21bb.ttf
Normal file
BIN
lenticular_cloud/static/b823fc0dbb5a5f0c21bb.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/e615bbcb258550973c16.ttf
Normal file
BIN
lenticular_cloud/static/e615bbcb258550973c16.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/ebb7a127d2d8ee6f1832.woff2
Normal file
BIN
lenticular_cloud/static/ebb7a127d2d8ee6f1832.woff2
Normal file
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
|
@ -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)) }}
|
||||||
|
{#
|
||||||
{% 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 %}
|
||||||
|
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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({})
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
|
logger.info(f"user {current_user.username} changed password")
|
||||||
|
db.session.commit()
|
||||||
return jsonify({})
|
return jsonify({})
|
||||||
else:
|
|
||||||
return jsonify({'errors': {'internal': 'internal server errror'}})
|
|
||||||
return jsonify({'errors': form.errors})
|
return jsonify({'errors': form.errors})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
2953
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue