Compare commits

...

13 Commits

Author SHA1 Message Date
Tim Gröger 607b29027b update to version 2.2.0 2024-10-15 08:30:03 +02:00
Tim Gröger df02808fb7 [feat][apiKey] show token only on create, fix decorator 2024-10-15 08:26:16 +02:00
Tim Gröger 7dd3321246 [feat][apikey] update model, add logic, add routes 2024-10-15 07:27:29 +02:00
Tim Gröger 2f7fdec492 [feat] add api_key table
create model for api_key
create migration for alembic
2024-10-14 06:24:19 +02:00
Tim Gröger 81080404fb [fix] fix multiple sessions
Session is created with user.id now instead of full user. Old sessions
dont will destroy.
2024-10-10 13:29:05 +02:00
Tim Gröger 0570a9a32f update to version 2.1.0 2024-10-08 15:05:08 +02:00
Tim Gröger c06a12faaa [feat] add user settings 2024-04-12 10:11:45 +02:00
Tim Gröger 4f20a94f60 fix some func to get balance 2024-01-17 13:04:29 +01:00
Tim Gröger 001ef13014 remove links 2024-01-17 00:20:40 +01:00
Tim Gröger 0ae334620b update dependencies 2024-01-16 22:43:49 +01:00
Tim Gröger 645e2865a6 update dependencies 2024-01-16 22:34:35 +01:00
Tim Gröger bddb11d1b4 update version to 2.0.0 2024-01-16 19:49:56 +01:00
Tim Gröger cab172dc65 fix floor transaction with value which has more ziffers than scale #33 2023-05-17 14:47:40 +02:00
14 changed files with 423 additions and 46 deletions

View File

@ -0,0 +1,37 @@
"""add name and description to api_key
Revision ID: 49118ea16b56
Revises: f9aa4cafa982
Create Date: 2024-10-14 08:15:16.348090
"""
import sqlalchemy as sa
from alembic import op
import flaschengeist
# revision identifiers, used by Alembic.
revision = "49118ea16b56"
down_revision = "f9aa4cafa982"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("api_key", schema=None) as batch_op:
batch_op.add_column(sa.Column("name", sa.String(length=32), nullable=True))
batch_op.add_column(sa.Column("description", sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("api_key", schema=None) as batch_op:
batch_op.drop_column("description")
batch_op.drop_column("name")
# ### end Alembic commands ###

View File

@ -0,0 +1,41 @@
"""Add APIKeys
Revision ID: f9aa4cafa982
Revises: 20482a003db8
Create Date: 2024-10-11 13:04:21.877288
"""
import sqlalchemy as sa
from alembic import op
import flaschengeist
# revision identifiers, used by Alembic.
revision = "f9aa4cafa982"
down_revision = "20482a003db8"
branch_labels = ()
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"api_key",
sa.Column("expires", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.Column("token", sa.String(length=32), nullable=True),
sa.Column("lifetime", sa.Integer(), nullable=True),
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("user_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_api_key_user_id_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_api_key")),
sa.UniqueConstraint("token", name=op.f("uq_api_key_token")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("api_key")
# ### end Alembic commands ###

View File

@ -0,0 +1,79 @@
import secrets
from werkzeug.exceptions import Unauthorized
from .. import logger
from ..database import db
from ..models import ApiKey
def validate_api_key(api_key, permission):
"""Verify api key
Verify a ApiKey so if the User has permission or not.
Retrieves the access token if valid else retrieves False
Args:
api_key: ApiKey to verify
permission: Permission needed to access restricted routes
Returns:
A ApiKey for this given Token
Raises:
Unauthorized: If api key is invalid
Forbidden: If permission is insufficient
"""
logger.debug("check api_key {{ {} }} is valid".format(api_key))
api_key = ApiKey.query.filter_by(_token=api_key).one_or_none()
if api_key:
logger.debug("api_key found")
if not permission or api_key.user_.has_permission(permission):
return api_key
else:
raise Forbidden
logger.debug("no valid api key with api_key: {{ {} }} and permission: {{ {} }}".format(api_key, permission))
raise Unauthorized
def create(user, name, description=None) -> ApiKey:
"""Create a ApiKey
Args:
user: For which User is to create a ApiKey
Returns:
A ApiKey for this given User
"""
logger.debug("create api key token")
token_str = secrets.token_hex(16)
logger.debug("create api_key for user {{ {} }}".format(user))
api_key = ApiKey(_user_id=user.id_, name=name, description=description, _token=token_str)
db.session.add(api_key)
db.session.commit()
api_key.token = api_key._token
return api_key
def get_users_api_keys(user) -> list[ApiKey]:
"""Get all ApiKeys for a User
Args:
user: For which User is to get all ApiKeys
Returns:
List of ApiKeys for this given User
"""
return ApiKey.query.filter(ApiKey._user_id == user.id_).all()
def delete_api_key(api_key):
"""Delete a ApiKey
Args:
api_key: ApiKey to delete
"""
logger.debug(f"delete api_key {{ {api_key} }} {{ {type(api_key)} }}")
if isinstance(api_key, int):
api_key = ApiKey.query.get(api_key)
logger.debug("delete api_key {{ {} }}".format(api_key.token))
db.session.delete(api_key)
db.session.commit()

View File

@ -1,13 +1,12 @@
import secrets
from datetime import datetime, timezone
from werkzeug.exceptions import Forbidden, Unauthorized
from ua_parser import user_agent_parser
from werkzeug.exceptions import Forbidden, Unauthorized
from .. import logger
from ..models import Session
from ..database import db
from ..models import Session
lifetime = 1800
@ -72,7 +71,7 @@ def create(user, request_headers=None) -> Session:
logger.debug(f"platform: {user_agent['os']['family']}, browser: {user_agent['user_agent']['family']}")
session = Session(
token=token_str,
user_=user,
_user_id=user.id_,
lifetime=lifetime,
platform=user_agent["os"]["family"],
browser=user_agent["user_agent"]["family"],

View File

@ -1,5 +1,6 @@
from .api_key import *
from .image import *
from .notification import *
from .plugin import *
from .session import *
from .user import *
from .plugin import *
from .notification import *
from .image import *

View File

@ -0,0 +1,52 @@
from __future__ import \
annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from datetime import datetime, timedelta, timezone
from secrets import compare_digest
from typing import Union
from .. import logger
from ..database import db
from ..database.types import ModelSerializeMixin, Serial, UtcDateTime
class ApiKey(db.Model, ModelSerializeMixin):
"""Model for a Session
Args:
expires: Is a Datetime from current Time.
user: Is an User.
token: String to verify access later.
"""
__allow_unmapped__ = True
__tablename__ = "api_key"
expires: datetime = db.Column(UtcDateTime, nullable=True)
_token: str = db.Column("token", db.String(32), unique=True)
name: str = db.Column(db.String(32))
description: str = db.Column(db.String(255), nullable=True)
lifetime: int = db.Column(db.Integer, nullable=True)
userid: str = ""
id: int = db.Column("id", Serial, primary_key=True)
_user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"))
user_: User = db.relationship("User", back_populates="api_keys_")
token: Union[str, None] = None
@property
def userid(self):
return self.user_.userid
def refresh(self):
"""Update the Timestamp
Update the Timestamp to the current Time.
"""
logger.debug("update timestamp from session with token {{ {} }}".format(self._token))
self.expires = datetime.now(timezone.utc) + timedelta(seconds=self.lifetime)
def __eq__(self, token):
if isinstance(token, str):
return compare_digest(self._token, token)
else:
return super(Session, self).__eq__(token)

View File

@ -1,13 +1,13 @@
from __future__ import (
annotations,
) # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from __future__ import \
annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from typing import Optional, Union, List
from datetime import date, datetime
from typing import List, Optional, Union
from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db
from ..database.types import ModelSerializeMixin, UtcDateTime, Serial
from ..database.types import ModelSerializeMixin, Serial, UtcDateTime
association_table = db.Table(
"user_x_role",
@ -71,6 +71,7 @@ class User(db.Model, ModelSerializeMixin):
id_ = db.Column("id", Serial, primary_key=True)
roles_: List[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge")
sessions_: List[Session] = db.relationship("Session", back_populates="user_", cascade="all, delete, delete-orphan")
api_keys_: List[ApiKey] = db.relationship("ApiKey", back_populates="user_", cascade="all, delete, delete-orphan")
avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True)
reset_requests_: List["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan")

View File

@ -6,13 +6,15 @@ from werkzeug.exceptions import NotFound
@click.command(no_args_is_help=True)
@click.option("--sync", is_flag=True, default=False, help="Synchronize users from LDAP -> database")
@click.option("--sync-ldap", is_flag=True, default=False, help="Synchronize users from database -> LDAP")
@with_appcontext
@click.pass_context
def ldap(ctx, sync):
def ldap(ctx, sync, sync_ldap):
"""Tools for the LDAP authentification"""
from flaschengeist.controller import userController
from flaschengeist.plugins.auth_ldap import AuthLDAP
if sync:
from flaschengeist.controller import userController
from flaschengeist.plugins.auth_ldap import AuthLDAP
click.echo("Synchronizing users from LDAP -> database")
from ldap3 import SUBTREE
from flaschengeist.models import User
from flaschengeist.database import db
@ -33,3 +35,13 @@ def ldap(ctx, sync):
user = User(userid=uid)
db.session.add(user)
userController.update_user(user, auth_ldap)
if sync_ldap:
click.echo("Synchronizing users from database -> LDAP")
auth_ldap: AuthLDAP = current_app.config.get("FG_PLUGINS").get("auth_ldap")
if auth_ldap is None or not isinstance(auth_ldap, AuthLDAP):
ctx.fail("auth_ldap plugin not found or not enabled!")
users = userController.get_users()
for user in users:
userController.update_user(user, auth_ldap)

View File

@ -36,7 +36,7 @@ def get_limit(user: User) -> float:
def get_balance(user, start: datetime = None, end: datetime = None):
query = db.session.query(func.sum(Transaction.amount))
query = db.session.query(func.sum(Transaction._amount))
if start:
query = query.filter(start <= Transaction.time)
if end:
@ -48,7 +48,13 @@ def get_balance(user, start: datetime = None, end: datetime = None):
def get_balances(
start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None, _filter=None
start: datetime = None,
end: datetime = None,
limit=None,
offset=None,
descending=None,
sortBy=None,
_filter=None,
):
logger.debug(
f"get_balances(start={start}, end={end}, limit={limit}, offset={offset}, descending={descending}, sortBy={sortBy}, _filter={_filter})"
@ -56,7 +62,11 @@ def get_balances(
class _User(User):
_debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id])
_credit = db.relationship(Transaction, back_populates="receiver_", foreign_keys=[Transaction._receiver_id])
_credit = db.relationship(
Transaction,
back_populates="receiver_",
foreign_keys=[Transaction._receiver_id],
)
@hybrid_property
def debit(self):
@ -65,8 +75,8 @@ def get_balances(
@debit.expression
def debit(cls):
a = (
db.select(func.sum(Transaction.amount))
.where(cls.id_ == Transaction._sender_id, Transaction.amount)
db.select(func.sum(Transaction._amount))
.where(cls.id_ == Transaction._sender_id, Transaction._amount)
.scalar_subquery()
)
return case([(a, a)], else_=0)
@ -78,8 +88,8 @@ def get_balances(
@credit.expression
def credit(cls):
b = (
db.select(func.sum(Transaction.amount))
.where(cls.id_ == Transaction._receiver_id, Transaction.amount)
db.select(func.sum(Transaction._amount))
.where(cls.id_ == Transaction._receiver_id, Transaction._amount)
.scalar_subquery()
)
return case([(b, b)], else_=0)
@ -92,7 +102,12 @@ def get_balances(
def limit(cls):
return (
db.select(_UserAttribute.value)
.where(and_(cls.id_ == _UserAttribute.user, _UserAttribute.name == "balance_limit"))
.where(
and_(
cls.id_ == _UserAttribute.user,
_UserAttribute.name == "balance_limit",
)
)
.scalar_subquery()
)
@ -127,14 +142,25 @@ def get_balances(
if _filter:
query = query.filter(
or_(_User.firstname.ilike(f"%{_filter.lower()}%"), _User.lastname.ilike(f"%{_filter.lower()}%"))
or_(
_User.firstname.ilike(f"%{_filter.lower()}%"),
_User.lastname.ilike(f"%{_filter.lower()}%"),
)
)
if sortBy == "balance":
if descending:
query = query.order_by((_User.credit - _User.debit).desc(), _User.lastname.asc(), _User.firstname.asc())
query = query.order_by(
(_User.credit - _User.debit).desc(),
_User.lastname.asc(),
_User.firstname.asc(),
)
else:
query = query.order_by((_User.credit - _User.debit).asc(), _User.lastname.asc(), _User.firstname.asc())
query = query.order_by(
(_User.credit - _User.debit).asc(),
_User.lastname.asc(),
_User.firstname.asc(),
)
elif sortBy == "limit":
if descending:
query = query.order_by(_User.limit.desc(), User.lastname.asc(), User.firstname.asc())
@ -209,7 +235,11 @@ def send(sender: User, receiver, amount: float, author: User):
BalancePlugin.getPlugin().notify(
sender,
"Neue Transaktion",
{"type": NotifyType.SUB_FROM, "author_id": author.userid, "amount": amount},
{
"type": NotifyType.SUB_FROM,
"author_id": author.userid,
"amount": amount,
},
)
if receiver is not None and receiver.id_ != author.id_:
if sender is not None:
@ -226,7 +256,11 @@ def send(sender: User, receiver, amount: float, author: User):
BalancePlugin.getPlugin().notify(
receiver,
"Neue Transaktion",
{"type": NotifyType.ADD_FROM, "author_id": author.userid, "amount": amount},
{
"type": NotifyType.ADD_FROM,
"author_id": author.userid,
"amount": amount,
},
)
return transaction

View File

@ -1,7 +1,9 @@
from datetime import datetime
from typing import Optional
from sqlalchemy.ext.hybrid import hybrid_property
from math import floor
from flaschengeist import logger
from flaschengeist.database import db
from flaschengeist.models.user import User
from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial
@ -18,8 +20,9 @@ class Transaction(db.Model, ModelSerializeMixin):
# Public and exported member
id: int = db.Column("id", Serial, primary_key=True)
time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc)
amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
_amount: float = db.Column("amount", db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
reversal_id: Optional[int] = db.Column(Serial, db.ForeignKey("balance_transaction.id"))
amount: float
# Dummy properties used for JSON serialization (userid instead of full user)
author_id: Optional[str] = None
@ -56,3 +59,14 @@ class Transaction(db.Model, ModelSerializeMixin):
@property
def original_id(self):
return self.original_.id if self.original_ else None
@property
def amount(self):
return self._amount
@amount.setter
def amount(self, value):
self._amount = floor(value * 100) / 100
def __repr__(self):
return f"<Transaction {self.id} {self.amount} {self.time} {self.sender_id} {self.receiver_id} {self.author_id}>"

View File

@ -1,4 +1,5 @@
from datetime import datetime, timezone
from logging import log
from werkzeug.exceptions import Forbidden, BadRequest
from flask import Blueprint, request, jsonify
@ -163,6 +164,7 @@ def get_balance(userid, current_session: Session):
end = datetime.now(tz=timezone.utc)
balance = balance_controller.get_balance(user, start, end)
logger.debug(f"Balance of {user.userid} from {start} to {end}: {balance}")
return {"credit": balance[0], "debit": balance[1], "balance": balance[2]}
@ -224,6 +226,7 @@ def get_transactions(userid, current_session: Session):
show_cancelled=show_cancelled,
descending=descending,
)
logger.debug(f"transactions: {transactions}")
return {"transactions": transactions, "count": count}
@ -321,7 +324,11 @@ def get_balances(current_session: Session):
_filter = request.args.get("filter", None, type=str)
logger.debug(f"request.args: {request.args}")
balances, count = balance_controller.get_balances(
limit=limit, offset=offset, descending=descending, sortBy=sortBy, _filter=_filter
limit=limit,
offset=offset,
descending=descending,
sortBy=sortBy,
_filter=_filter,
)
return jsonify(
{

View File

@ -2,20 +2,23 @@
Provides routes used to manage users
"""
from http.client import CREATED
from flask import Blueprint, request, jsonify, make_response, after_this_request, Response
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from datetime import datetime
from . import permissions
from datetime import datetime
from http.client import CREATED
from flask import Blueprint, Response, after_this_request, jsonify, make_response, request
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.plugins import Plugin
from flaschengeist.controller import apiKeyController, userController
from flaschengeist.models import User
from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.controller import userController
from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.plugins import Plugin
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.utils.decorators import extract_session, headers, login_required
from flaschengeist.utils.HTTP import created, no_content
from . import permissions
class UsersPlugin(Plugin):
@ -58,7 +61,7 @@ def register():
@UsersPlugin.blueprint.route("/users", methods=["GET"])
@login_required()
@headers({"Cache-Control": "private, must-revalidate, max-age=3600"})
# @headers({"Cache-Control": "private, must-revalidate, max-age=3600"})
def list_users(current_session):
"""List all existing users
@ -260,3 +263,82 @@ def shortcuts(userid, current_session):
user.set_attribute("users_link_shortcuts", data)
userController.persist()
return no_content()
@UsersPlugin.blueprint.route("/users/<userid>/setting/<setting>", methods=["GET", "PUT"])
@login_required()
def settings(userid, setting, current_session):
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
retVal = user.get_attribute(setting, None)
logger.debug(f"Get setting >>{setting}<< for user >>{user.userid}<< with >>{retVal}<<")
return jsonify(retVal)
else:
data = request.get_json()
logger.debug(f"Set setting >>{setting}<< for user >>{user.userid}<< to >>{data}<<")
user.set_attribute(setting, data)
userController.persist()
return no_content()
@UsersPlugin.blueprint.route("/users/<userid>/api_keys", methods=["GET"])
@login_required()
def get_users_api_keys(userid, current_session):
"""Get all API keys of a user
Route: ``/users/<userid>/api_keys`` | Method: ``GET``
Args:
userid: UserID of user to retrieve
current_session: Session sent with Authorization Header
Returns:
JSON encoded array of `flaschengeist.models.api_key.ApiKey` or HTTP error
"""
if userid != current_session.user_.userid:
raise Unauthorized
return jsonify(apiKeyController.get_users_api_keys(current_session.user_))
@UsersPlugin.blueprint.route("/users/<userid>/api_keys", methods=["POST"])
@login_required()
def create_api_key(userid, current_session):
"""Create a new API key for a user
Route: ``/users/<userid>/api_keys`` | Method: ``POST``
Args:
userid: UserID of user to retrieve
current_session: Session sent with Authorization Header
Returns:
JSON encoded `flaschengeist.models.api_key.ApiKey` or HTTP error
"""
data = request.get_json()
if not data or "name" not in data:
raise BadRequest
if userid != current_session.user_.userid:
raise Unauthorized
return jsonify(apiKeyController.create(current_session.user_, data["name"], data.get("description", None)))
@UsersPlugin.blueprint.route("/users/<userid>/api_keys/<int:keyid>", methods=["DELETE"])
@login_required()
def delete_api_key(userid, keyid, current_session):
"""Delete an API key for a user
Route: ``/users/<userid>/api_keys/<keyid>`` | Method: ``DELETE``
Args:
userid: UserID of user to retrieve
keyid: KeyID of the API key to delete
current_session: Session sent with Authorization Header
Returns:
HTTP-204 or HTTP error
"""
if userid != current_session.user_.userid:
raise Unauthorized
apiKeyController.delete_api_key(keyid)
return no_content()

View File

@ -1,8 +1,22 @@
from functools import wraps
from werkzeug.exceptions import Unauthorized
from flaschengeist import logger
from flaschengeist.controller import sessionController
from flaschengeist.controller import apiKeyController, sessionController
def extract_api_key(permission=None):
from flask import request
try:
apiKey = request.headers.get("X-API-KEY")
except AttributeError:
logger.debug("Missing X-API-KEY header")
raise Unauthorized
apiKey = apiKeyController.validate_api_key(apiKey, permission)
return apiKey
def extract_session(permission=None):
@ -32,7 +46,10 @@ def login_required(permission=None):
def wrap(func):
@wraps(func)
def wrapped_f(*args, **kwargs):
session = extract_session(permission)
try:
session = extract_session(permission)
except Unauthorized:
session = extract_api_key(permission)
kwargs["current_session"] = session
logger.debug("token {{ {} }} is valid".format(session.token))
return func(*args, **kwargs)

View File

@ -1,6 +1,6 @@
[metadata]
license = MIT
version = 2.0.0.dev1
version = 2.2.0
name = flaschengeist
author = Tim Gröger
author_email = flaschengeist@wu5.de
@ -22,7 +22,8 @@ include_package_data = True
python_requires = >=3.10
packages = find:
install_requires =
Flask>=2.2.2, <2.3
#Flask>=2.2.2, <2.3
Flask>=2.2.2, <2.9
Pillow>=9.2
flask_cors
flask_migrate>=3.1.0
@ -38,7 +39,7 @@ install_requires =
[options.extras_require]
argon = argon2-cffi
ldap = flask_ldapconn; ldap3
ldap = flask_ldapconn @ git+https://github.com/rroemhild/flask-ldapconn.git; ldap3
tests = pytest; pytest-depends; coverage
mysql =
PyMySQL;platform_system=='Windows'