From 9f75d983badad40af244dcb3bd3e15d2bc66eda1 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sun, 1 Jun 2025 10:51:32 +0200 Subject: [PATCH] add token loader, .flask_sqlalchemy, jsonencode(), base2048 --- CHANGELOG.md | 7 ++ pyproject.toml | 3 + src/suou/__init__.py | 9 +- src/suou/bits.py | 53 ++++++++++- src/suou/codecs.py | 170 +++++++++++++++++++++++++++++++---- src/suou/flask.py | 13 ++- src/suou/flask_restx.py | 40 ++++++++- src/suou/flask_sqlalchemy.py | 66 ++++++++++++++ src/suou/peewee.py | 2 +- src/suou/signing.py | 27 +++++- src/suou/sqlalchemy.py | 114 ++++++++++++++++++++--- 11 files changed, 462 insertions(+), 42 deletions(-) create mode 100644 src/suou/flask_sqlalchemy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7456e7c..58d94f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.0 + +- Add auth loaders i.e. `sqlalchemy.require_auth_base()`, `flask_sqlalchemy` +- Improve JSON handling in `flask_restx` +- Add base2048 (i.e. BIP-39) codec +- Add `split_bits()` and `join_bits()` + ## 0.2.3 - Bug fixes in `classtools` and `sqlalchemy` diff --git a/pyproject.toml b/pyproject.toml index f51d04c..ca602dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ flask = [ "Flask>=2.0.0", "Flask-RestX" ] +flask_sqlalchemy = [ + "Flask-SqlAlchemy" +] peewee = [ "peewee>=3.0.0, <4.0" ] diff --git a/src/suou/__init__.py b/src/suou/__init__.py index e10849c..5caf6cf 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -17,19 +17,20 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ from .iding import Siq, SiqCache, SiqType, SiqGen -from .codecs import StringCase, cb32encode, cb32decode, jsonencode -from .bits import count_ones, mask_shift +from .codecs import StringCase, cb32encode, cb32decode, jsonencode, want_bytes, want_str, b2048encode, b2048decode +from .bits import count_ones, mask_shift, split_bits, join_bits from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .functools import deprecated, not_implemented from .classtools import Wanted, Incomplete from .itertools import makelist, kwargs_prefix from .i18n import I18n, JsonI18n, TomlI18n -__version__ = "0.2.3" +__version__ = "0.3.0-dev21" __all__ = ( 'Siq', 'SiqCache', 'SiqType', 'SiqGen', 'StringCase', 'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'EnvConfigSource', 'DictConfigSource', 'deprecated', 'not_implemented', 'Wanted', 'Incomplete', 'jsonencode', - 'makelist', 'kwargs_prefix', 'I18n', 'JsonI18n', 'TomlI18n', 'cb32encode', 'cb32decode', 'count_ones', 'mask_shift' + 'makelist', 'kwargs_prefix', 'I18n', 'JsonI18n', 'TomlI18n', 'cb32encode', 'cb32decode', 'count_ones', 'mask_shift', + 'want_bytes', 'want_str', 'version', 'b2048encode', 'split_bits', 'join_bits', 'b2048decode' ) diff --git a/src/suou/bits.py b/src/suou/bits.py index 78bbf7c..8ab2053 100644 --- a/src/suou/bits.py +++ b/src/suou/bits.py @@ -14,6 +14,8 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ''' +import math + def mask_shift(n: int, mask: int) -> int: ''' Select the bits from n chosen by mask, least significant first. @@ -45,4 +47,53 @@ def count_ones(n: int) -> int: n >>= 1 return ones -__all__ = ('count_ones', 'mask_shift') \ No newline at end of file +def split_bits(buf: bytes, nbits: int) -> list[int]: + ''' + Split a bytestring into chunks of equal size, and interpret each chunk as an unsigned integer. + + XXX DOES NOT WORK DO NOT USE!!!!!!!! + ''' + mem = memoryview(buf) + chunk_size = nbits // math.gcd(nbits, 8) + est_len = math.ceil(len(buf) * 8 / nbits) + mask_n = chunk_size * 8 // nbits + numbers = [] + + off = 0 + while off < len(buf): + chunk = mem[off:off+chunk_size].tobytes() + if len(chunk) < chunk_size: + chunk = chunk + b'\0' * (chunk_size - len(chunk)) + num = int.from_bytes(chunk, 'big') + for j in range(mask_n): + numbers.append(mask_shift(num, ((1 << nbits) - 1) << ((mask_n - 1 - j) * nbits) )) + off += chunk_size + assert sum(numbers[est_len:]) == 0, str(f'{chunk_size=} {len(numbers)=} {est_len=} {numbers[est_len:]=}') + return numbers[:est_len] + + +def join_bits(l: list[int], nbits: int) -> bytes: + """ + Concatenate a list of integers into a bytestring. + """ + chunk_size = nbits // math.gcd(nbits, 8) + chunk = 0 + mask_n = chunk_size * 8 // nbits + ou = b'' + + chunk, j = 0, mask_n - 1 + for num in l: + chunk |= num << nbits * j + if j <= 0: + ou += chunk.to_bytes(chunk_size, 'big') + chunk, j = 0, mask_n - 1 + else: + j -= 1 + else: + if chunk != 0: + ou += chunk.to_bytes(chunk_size, 'big') + return ou + + + +__all__ = ('count_ones', 'mask_shift', 'split_bits', 'join_bits') diff --git a/src/suou/codecs.py b/src/suou/codecs.py index 537780b..5f74867 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -18,10 +18,36 @@ import base64 import datetime import enum import json +import math import re from typing import Any, Callable -from suou.functools import not_implemented +from .bits import split_bits, join_bits +from .functools import deprecated + +# yes, I know ItsDangerous implements that as well, but remember +# what happened with werkzeug.safe_str_cmp()? +# see also: https://gitlab.com/wcorrales/quart-csrf/-/issues/1 + +def want_bytes(s: str | bytes, encoding: str = "utf-8", errors: str = "strict") -> bytes: + """ + Force a string into its bytes representation. + + By default, UTF-8 encoding is assumed. + """ + if isinstance(s, str): + s = s.encode(encoding, errors) + return s + +def want_str(s: str | bytes, encoding: str = "utf-8", errors: str = "strict") -> str: + """ + Convert a bytestring into a text string. + + By default, UTF-8 encoding is assumed. + """ + if isinstance(s, bytes): + s = s.decode(encoding, errors) + return s B32_TO_CROCKFORD = str.maketrans( 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', @@ -33,49 +59,157 @@ CROCKFORD_TO_B32 = str.maketrans( 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', '=') +BIP39_WORD_LIST = """ +abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action +actor actress actual adapt add addict address adjust admit adult advance advice aerobic affair afford afraid again age agent agree ahead aim air airport +aisle alarm album alcohol alert alien all alley allow almost alone alpha already also alter always amateur amazing among amount amused analyst anchor +ancient anger angle angry animal ankle announce annual another answer antenna antique anxiety any apart apology appear apple approve april arch +arctic area arena argue arm armed armor army around arrange arrest arrive arrow art artefact artist artwork ask aspect assault asset assist assume +asthma athlete atom attack attend attitude attract auction audit august aunt author auto autumn average avocado avoid awake aware away awesome +awful awkward axis baby bachelor bacon badge bag balance balcony ball bamboo banana banner bar barely bargain barrel base basic basket battle +beach bean beauty because become beef before begin behave behind believe below belt bench benefit best betray better between beyond bicycle bid +bike bind biology bird birth bitter black blade blame blanket blast bleak bless blind blood blossom blouse blue blur blush board boat body boil bomb +bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain brand brass brave bread breeze brick bridge brief bright bring +brisk broccoli broken bronze broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker burden burger burst bus +business busy butter buyer buzz cabbage cabin cable cactus cage cake call calm camera camp can canal cancel candy cannon canoe canvas canyon +capable capital captain car carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch category cattle caught cause caution +cave ceiling celery cement census century cereal certain chair chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry +chest chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify claw clay clean clerk +clever click client cliff climb clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut code coffee coil coin collect color +column combine come comfort comic common company concert conduct confirm congress connect consider control convince cook cool copper copy coral +core corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft cram crane crash crater crawl crazy cream credit creek +crew cricket crime crisp critic crop cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious current +curtain curve cushion custom cute cycle dad damage damp dance danger daring dash daughter dawn day deal debate debris decade december decide +decline decorate decrease deer defense define defy degree delay deliver demand demise denial dentist deny depart depend deposit depth deputy derive +describe desert design desk despair destroy detail detect develop device devote diagram dial diamond diary dice diesel diet differ digital dignity dilemma +dinner dinosaur direct dirt disagree discover disease dish dismiss disorder display distance divert divide divorce dizzy doctor document dog doll dolphin +domain donate donkey donor door dose double dove draft dragon drama drastic draw dream dress drift drill drink drip drive drop drum dry duck dumb +dune during dust dutch duty dwarf dynamic eager eagle early earn earth easily east easy echo ecology economy edge edit educate effort egg eight +either elbow elder electric elegant element elephant elevator elite else embark embody embrace emerge emotion employ empower empty enable enact +end endless endorse enemy energy enforce engage engine enhance enjoy enlist enough enrich enroll ensure enter entire entry envelope episode equal +equip era erase erode erosion error erupt escape essay essence estate eternal ethics evidence evil evoke evolve exact example excess exchange excite +exclude excuse execute exercise exhaust exhibit exile exist exit exotic expand expect expire explain expose express extend extra eye eyebrow fabric face +faculty fade faint faith fall false fame family famous fan fancy fantasy farm fashion fat fatal father fatigue fault favorite feature february federal fee +feed feel female fence festival fetch fever few fiber fiction field figure file film filter final find fine finger finish fire firm first fiscal fish fit fitness fix flag +flame flash flat flavor flee flight flip float flock floor flower fluid flush fly foam focus fog foil fold follow food foot force forest forget fork fortune forum +forward fossil foster found fox fragile frame frequent fresh friend fringe frog front frost frown frozen fruit fuel fun funny furnace fury future gadget gain +galaxy gallery game gap garage garbage garden garlic garment gas gasp gate gather gauge gaze general genius genre gentle genuine gesture ghost +giant gift giggle ginger giraffe girl give glad glance glare glass glide glimpse globe gloom glory glove glow glue goat goddess gold good goose gorilla +gospel gossip govern gown grab grace grain grant grape grass gravity great green grid grief grit grocery group grow grunt guard guess guide guilt +guitar gun gym habit hair half hammer hamster hand happy harbor hard harsh harvest hat have hawk hazard head health heart heavy hedgehog height +hello helmet help hen hero hidden high hill hint hip hire history hobby hockey hold hole holiday hollow home honey hood hope horn horror horse hospital +host hotel hour hover hub huge human humble humor hundred hungry hunt hurdle hurry hurt husband hybrid ice icon idea identify idle ignore ill illegal +illness image imitate immense immune impact impose improve impulse inch include income increase index indicate indoor industry infant inflict inform +inhale inherit initial inject injury inmate inner innocent input inquiry insane insect inside inspire install intact interest into invest invite involve iron island +isolate issue item ivory jacket jaguar jar jazz jealous jeans jelly jewel job join joke journey joy judge juice jump jungle junior junk just kangaroo keen +keep ketchup key kick kid kidney kind kingdom kiss kit kitchen kite kitten kiwi knee knife knock know lab label labor ladder lady lake lamp language +laptop large later latin laugh laundry lava law lawn lawsuit layer lazy leader leaf learn leave lecture left leg legal legend leisure lemon lend length lens +leopard lesson letter level liar liberty library license life lift light like limb limit link lion liquid list little live lizard load loan lobster local lock logic lonely +long loop lottery loud lounge love loyal lucky luggage lumber lunar lunch luxury lyrics machine mad magic magnet maid mail main major make mammal +man manage mandate mango mansion manual maple marble march margin marine market marriage mask mass master match material math matrix +matter maximum maze meadow mean measure meat mechanic medal media melody melt member memory mention menu mercy merge merit merry mesh +message metal method middle midnight milk million mimic mind minimum minor minute miracle mirror misery miss mistake mix mixed mixture mobile +model modify mom moment monitor monkey monster month moon moral more morning mosquito mother motion motor mountain mouse move movie +much muffin mule multiply muscle museum mushroom music must mutual myself mystery myth naive name napkin narrow nasty nation nature near neck +need negative neglect neither nephew nerve nest net network neutral never news next nice night noble noise nominee noodle normal north nose notable +note nothing notice novel now nuclear number nurse nut oak obey object oblige obscure observe obtain obvious occur ocean october odor off offer office +often oil okay old olive olympic omit once one onion online only open opera opinion oppose option orange orbit orchard order ordinary organ orient +original orphan ostrich other outdoor outer output outside oval oven over own owner oxygen oyster ozone pact paddle page pair palace palm panda +panel panic panther paper parade parent park parrot party pass patch path patient patrol pattern pause pave payment peace peanut pear peasant pelican +pen penalty pencil people pepper perfect permit person pet phone photo phrase physical piano picnic picture piece pig pigeon pill pilot pink pioneer pipe +pistol pitch pizza place planet plastic plate play please pledge pluck plug plunge poem poet point polar pole police pond pony pool popular portion +position possible post potato pottery poverty powder power practice praise predict prefer prepare present pretty prevent price pride primary print +priority prison private prize problem process produce profit program project promote proof property prosper protect proud provide public pudding pull +pulp pulse pumpkin punch pupil puppy purchase purity purpose purse push put puzzle pyramid quality quantum quarter question quick quit quiz quote +rabbit raccoon race rack radar radio rail rain raise rally ramp ranch random range rapid rare rate rather raven raw razor ready real reason rebel +rebuild recall receive recipe record recycle reduce reflect reform refuse region regret regular reject relax release relief rely remain remember remind +remove render renew rent reopen repair repeat replace report require rescue resemble resist resource response result retire retreat return reunion +reveal review reward rhythm rib ribbon rice rich ride ridge rifle right rigid ring riot ripple risk ritual rival river road roast robot robust rocket romance +roof rookie room rose rotate rough round route royal rubber rude rug rule run runway rural sad saddle sadness safe sail salad salmon salon salt salute +same sample sand satisfy satoshi sauce sausage save say scale scan scare scatter scene scheme school science scissors scorpion scout scrap screen +script scrub sea search season seat second secret section security seed seek segment select sell seminar senior sense sentence series service session +settle setup seven shadow shaft shallow share shed shell sheriff shield shift shine ship shiver shock shoe shoot shop short shoulder shove shrimp shrug +shuffle shy sibling sick side siege sight sign silent silk silly silver similar simple since sing siren sister situate six size skate sketch ski skill skin skirt +skull slab slam sleep slender slice slide slight slim slogan slot slow slush small smart smile smoke smooth snack snake snap sniff snow soap soccer +social sock soda soft solar soldier solid solution solve someone song soon sorry sort soul sound soup source south space spare spatial spawn speak +special speed spell spend sphere spice spider spike spin spirit split spoil sponsor spoon sport spot spray spread spring spy square squeeze squirrel +stable stadium staff stage stairs stamp stand start state stay steak steel stem step stereo stick still sting stock stomach stone stool story stove +strategy street strike strong struggle student stuff stumble style subject submit subway success such sudden suffer sugar suggest suit summer sun +sunny sunset super supply supreme sure surface surge surprise surround survey suspect sustain swallow swamp swap swarm swear sweet swift swim +swing switch sword symbol symptom syrup system table tackle tag tail talent talk tank tape target task taste tattoo taxi teach team tell ten tenant +tennis tent term test text thank that theme then theory there they thing this thought three thrive throw thumb thunder ticket tide tiger tilt timber time +tiny tip tired tissue title toast tobacco today toddler toe together toilet token tomato tomorrow tone tongue tonight tool tooth top topic topple torch +tornado tortoise toss total tourist toward tower town toy track trade traffic tragic train transfer trap trash travel tray treat tree trend trial tribe trick +trigger trim trip trophy trouble truck true truly trumpet trust truth try tube tuition tumble tuna tunnel turkey turn turtle twelve twenty twice twin twist +two type typical ugly umbrella unable unaware uncle uncover under undo unfair unfold unhappy uniform unique unit universe unknown unlock until +unusual unveil update upgrade uphold upon upper upset urban urge usage use used useful useless usual utility vacant vacuum vague valid valley valve +van vanish vapor various vast vault vehicle velvet vendor venture venue verb verify version very vessel veteran viable vibrant vicious victory video view +village vintage violin virtual virus visa visit visual vital vivid vocal voice void volcano volume vote voyage wage wagon wait walk wall walnut want +warfare warm warrior wash wasp waste water wave way wealth weapon wear weasel weather web wedding weekend weird welcome west wet whale +what wheat wheel when where whip whisper wide width wife wild will win window wine wing wink winner winter wire wisdom wise wish witness wolf +woman wonder wood wool word work world worry worth wrap wreck wrestle wrist write wrong yard year yellow you young youth zebra zero zone zoo +""".split() + +BIP39_DECODE_MATRIX = {v[:4]: i for i, v in enumerate(BIP39_WORD_LIST)} + def cb32encode(val: bytes) -> str: ''' Encode bytes in Crockford Base32. ''' - return base64.b32encode(val).decode('ascii').translate(B32_TO_CROCKFORD) - + return want_str(base64.b32encode(val)).translate(B32_TO_CROCKFORD) def cb32decode(val: bytes | str) -> str: ''' Decode bytes from Crockford Base32. ''' - if isinstance(val, str): - val = val.encode('ascii') - return base64.b32decode(val.upper().translate(CROCKFORD_TO_B32) + b'=' * ((5 - len(val) % 5) % 5)) + return base64.b32decode(want_bytes(val).upper().translate(CROCKFORD_TO_B32) + b'=' * ((5 - len(val) % 5) % 5)) def b32lencode(val: bytes) -> str: ''' Encode bytes as a lowercase base32 string, with trailing '=' stripped. ''' - return base64.b32encode(val).decode('ascii').rstrip('=').lower() + return want_str(base64.b32encode(val)).rstrip('=').lower() def b32ldecode(val: bytes | str) -> bytes: ''' Decode a lowercase base32 encoded byte sequence. Padding is managed automatically. ''' - if isinstance(val, str): - val = val.encode('ascii') - return base64.b32decode(val.upper() + b'=' * ((5 - len(val) % 5) % 5)) + return base64.b32decode(want_bytes(val).upper() + b'=' * ((5 - len(val) % 5) % 5)) def b64encode(val: bytes, *, strip: bool = True) -> str: ''' Wrapper around base64.urlsafe_b64encode() which also strips trailing '=' and leading 'A'. ''' - b = base64.urlsafe_b64encode(val).decode('ascii') + b = want_str(base64.urlsafe_b64encode(val)) return b.lstrip('A').rstrip('=') if strip else b def b64decode(val: bytes | str) -> bytes: ''' Wrapper around base64.urlsafe_b64decode() which deals with padding. ''' - if isinstance(val, str): - val = val.encode('ascii') - return base64.urlsafe_b64decode(val.replace(b'/', b'_').replace(b'+', b'-') + b'=' * ((4 - len(val) % 4) % 4)) + return base64.urlsafe_b64decode(want_bytes(val).replace(b'/', b'_').replace(b'+', b'-') + b'=' * ((4 - len(val) % 4) % 4)) + +def b2048encode(val: bytes) -> str: + ''' + Encode a bytestring using the BIP-39 wordlist. + ''' + return ' '.join(BIP39_WORD_LIST[x] for x in split_bits(val, 11)) + + +def b2048decode(val: bytes | str, *, strip = True) -> bytes: + """ + Decode a BIP-39 encoded string into bytes. + """ + try: + words = [BIP39_DECODE_MATRIX[x[:4]] for x in re.sub(r'[^a-z]+', ' ', want_str(val).lower()).split()] + except KeyError: + raise ValueError('illegal character') + b = join_bits(words, 11) + if strip: + assert b[math.ceil(len(words) * 11 / 8):].rstrip(b'\0') == b'' + b = b[:math.ceil(len(words) * 11 / 8)] + return b + def _json_default(func = None) -> Callable[Any, str | list | dict]: def default_converter(obj: Any) -> str | list | dict: @@ -89,10 +223,12 @@ def _json_default(func = None) -> Callable[Any, str | list | dict]: def jsonencode(obj: dict, *, skipkeys: bool = True, separators: tuple[str, str] = (',', ':'), default: Callable | None = None, **kwargs) -> str: ''' - JSON encoder with stricter and smarter defaults. + json.dumps() but with stricter and smarter defaults, i.e. no whitespace in separators, and encoding dates as ISO strings. ''' return json.dumps(obj, skipkeys=skipkeys, separators=separators, default=_json_default(default), **kwargs) +jsondecode = deprecated('just use json.loads()')(json.loads) + class StringCase(enum.Enum): """ Enum values used by regex validators and storage converters. @@ -106,7 +242,7 @@ class StringCase(enum.Enum): AS_IS = 0 LOWER = FORCE_LOWER = 1 UPPER = FORCE_UPPER = 2 - ## difference between above and below is in storage and representation. + ## difference between above and below is in storage and representation IGNORE_LOWER = IGNORE = 3 IGNORE_UPPER = 4 @@ -128,5 +264,5 @@ class StringCase(enum.Enum): __all__ = ( 'cb32encode', 'cb32decode', 'b32lencode', 'b32ldecode', 'b64encode', 'b64decode', 'jsonencode' - 'StringCase' + 'StringCase', 'want_bytes', 'want_str', 'jsondecode' ) \ No newline at end of file diff --git a/src/suou/flask.py b/src/suou/flask.py index e4f8f65..97f1b16 100644 --- a/src/suou/flask.py +++ b/src/suou/flask.py @@ -14,7 +14,8 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ -from flask import Flask, g, request +from typing import Any +from flask import Flask, current_app, g, request from .i18n import I18n from .configparse import ConfigOptions @@ -57,6 +58,14 @@ def add_i18n(app: Flask, i18n: I18n, var_name: str = 'T', *, return app -__all__ = ('add_context_from_config', 'add_i18n') +def get_flask_conf(key: str, default = None, *, app: Flask | None = None) -> Any: + ''' + Get a Flask configuration value + ''' + if not app: + app = current_app + return app.config.get(key, default) + +__all__ = ('add_context_from_config', 'add_i18n', 'get_flask_conf') diff --git a/src/suou/flask_restx.py b/src/suou/flask_restx.py index e921fa2..9d4955a 100644 --- a/src/suou/flask_restx.py +++ b/src/suou/flask_restx.py @@ -14,12 +14,44 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ -from typing import Mapping +from typing import Any, Mapping +import warnings +from flask import current_app, make_response from flask_restx import Api as _Api +from .codecs import jsonencode + + +def output_json(data, code, headers=None): + """Makes a Flask response with a JSON encoded body. + + The difference with flask_restx.representations handler of the + same name is suou.codecs.jsonencode() being used in place of plain json.dumps(). + + Opinionated: some RESTX_JSON settings are ignored. + """ + + try: + settings: dict = current_app.config.get("RESTX_JSON", {}).copy() + settings.pop('indent', 0) + settings.pop('separators', 0) + except TypeError: + warnings.warn('illegal value for RESTX_JSON', UserWarning) + settings = {} + + # always end the json dumps with a new line + # see https://github.com/mitsuhiko/flask/pull/1262 + dumped = jsonencode(data, **settings) + "\n" + + resp = make_response(dumped, code) + resp.headers.extend(headers or {}) + return resp + class Api(_Api): """ - Fix Api() class by remapping .message to .error + Improved flask_restx.Api() with better defaults. + + Notably, all JSON is whitespace-free and .message is remapped to .error """ def handle_error(self, e): res = super().handle_error(e) @@ -27,5 +59,9 @@ class Api(_Api): res['error'] = res['message'] del res['message'] return res + def __init__(self, *a, **ka): + super().__init__(*a, **ka) + self.representations['application/json'] = output_json + __all__ = ('Api',) \ No newline at end of file diff --git a/src/suou/flask_sqlalchemy.py b/src/suou/flask_sqlalchemy.py new file mode 100644 index 0000000..dfe05af --- /dev/null +++ b/src/suou/flask_sqlalchemy.py @@ -0,0 +1,66 @@ +""" +Utilities for Flask-SQLAlchemy binding. + +--- + +Copyright (c) 2025 Sakuragasaki46. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +See LICENSE for the specific language governing permissions and +limitations under the License. + +This software is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +""" + +from functools import partial +from typing import Any, Callable, Never + +from flask import abort, request +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import DeclarativeBase, Session + +from .sqlalchemy import require_auth_base + +class FlaskAuthSrc(AuthSrc): + ''' + + ''' + db: SQLAlchemy + def __init__(self, db: SQLAlchemy): + super().__init__() + self.db = db + def get_session(self) -> Session: + return self.db.session + def get_token(self): + return request.authorization.token + + def invalid_exc(self, msg: str = 'validation failed') -> Never: + abort(400, msg) + def required_exc(self): + abort(401) + +def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]: + """ + Make an auth_required() decorator for Flask views. + + This looks for a token in the Authorization header, validates it, loads the + appropriate object, and injects it as the user= parameter. + + cls is a SQLAlchemy table. + db is a flask_sqlalchemy.SQLAlchemy() binding. + + Usage: + + auth_required = require_auth(User, db) + + @route('/admin') + @auth_required(validators=[lambda x: x.is_administrator]) + def super_secret_stuff(user): + pass + """ + return partial(require_auth_base, cls=cls, src=FlaskAuthSrc(db)) + + +__all__ = ('require_auth', ) diff --git a/src/suou/peewee.py b/src/suou/peewee.py index 939ebce..f5b9403 100644 --- a/src/suou/peewee.py +++ b/src/suou/peewee.py @@ -73,7 +73,7 @@ def connect_reconnect(db): return db -## END async helperss +## END async helpers for Peewee class RegexCharField(CharField): ''' diff --git a/src/suou/signing.py b/src/suou/signing.py index 3595cbf..02a7e2a 100644 --- a/src/suou/signing.py +++ b/src/suou/signing.py @@ -14,15 +14,17 @@ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. """ +from abc import ABC +from base64 import b64decode +from typing import Any, Callable from itsdangerous import TimestampSigner -from suou.iding import Siq +from .codecs import want_str +from .iding import Siq class UserSigner(TimestampSigner): """ - Instance itsdangerous.TimestampSigner() from a user ID. - - XXX UNTESTED!!! + itsdangerous.TimestampSigner() instanced from a user ID, with token generation and validation capabilities. """ user_id: int def __init__(self, master_secret: bytes, user_id: int, user_secret: bytes, **kwargs): @@ -30,4 +32,21 @@ class UserSigner(TimestampSigner): self.user_id = user_id def token(self) -> str: return self.sign(Siq(self.user_id).to_base64()).decode('ascii') + @classmethod + def split_token(cls, /, token: str | bytes) : + a, b, c = want_str(token).rsplit('.', 2) + return b64decode(a), b, b64decode(c) + +class HasSigner(ABC): + ''' + Abstract base class for INTERNAL USE. + ''' + signer: Callable[Any, UserSigner] + + @classmethod + def __instancehook__(cls, obj) -> bool: + return callable(getattr(obj, 'signer', None)) + + +__all__ = ('UserSigner', ) \ No newline at end of file diff --git a/src/suou/sqlalchemy.py b/src/suou/sqlalchemy.py index e66bd81..0d0b83d 100644 --- a/src/suou/sqlalchemy.py +++ b/src/suou/sqlalchemy.py @@ -16,33 +16,39 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from __future__ import annotations -from typing import Callable +from abc import ABCMeta, abstractmethod +from functools import wraps +from typing import Any, Callable, Iterable, Never, TypeVar import warnings -from sqlalchemy import CheckConstraint, Date, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, text -from sqlalchemy.orm import DeclarativeBase, declarative_base as _declarative_base +from sqlalchemy import CheckConstraint, Date, Dialect, ForeignKey, LargeBinary, Column, MetaData, SmallInteger, String, select, text +from sqlalchemy.orm import DeclarativeBase, Session, declarative_base as _declarative_base -from suou.itertools import kwargs_prefix - -from .signing import UserSigner +from .itertools import kwargs_prefix, makelist +from .signing import HasSigner, UserSigner from .codecs import StringCase from .functools import deprecated from .iding import SiqType, SiqCache from .classtools import Incomplete, Wanted +_T = TypeVar('_T') + # SIQs are 14 bytes long. Storage is padded for alignment # Not to be confused with SiqType. IdType = LargeBinary(16) -def sql_escape(s: str, /, dialect: str) -> str: +def sql_escape(s: str, /, dialect: Dialect) -> str: """ Escape a value for SQL embedding, using SQLAlchemy's literal processors. Requires a dialect argument. + + XXX this function is not mature yet, do not use """ if isinstance(s, str): return String().literal_processor(dialect=dialect)(s) raise TypeError('invalid data type') + def id_column(typ: SiqType, *, primary_key: bool = True): """ Marks a column which contains a SIQ. @@ -109,9 +115,7 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete Requires a master secret (taken from Base.metadata), a user id (visible in the token) and a user secret. """ - if isinstance(id_attr, Incomplete): - raise TypeError('attempt to pass an uninstanced column. Pass the column name as a string instead.') - elif isinstance(id_attr, Column): + if isinstance(id_attr, Column): id_val = id_attr elif isinstance(id_attr, str): id_val = Wanted(id_attr) @@ -126,6 +130,7 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete return my_signer return Incomplete(Wanted(token_signer_factory)) + def author_pair(fk_name: str, *, id_type: type = IdType, sig_type: type | None = None, nullable: bool = False, sig_length: int | None = 2048, **ka) -> tuple[Column, Column]: """ Return an owner ID/signature column pair, for authenticated values. @@ -136,6 +141,7 @@ def author_pair(fk_name: str, *, id_type: type = IdType, sig_type: type | None = sig_col = Column(sig_type or LargeBinary(sig_length), nullable = nullable, **sig_ka) return (id_col, sig_col) + def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]: """ Return a SIS-compliant age representation, i.e. a date and accuracy pair. @@ -154,7 +160,93 @@ def age_pair(*, nullable: bool = False, **ka) -> tuple[Column, Column]: return (date_col, acc_col) +def want_column(cls: type[DeclarativeBase], col: Column[_T] | str) -> Column[_T]: + """ + Return a table's column given its name. + + XXX does it belong outside any scopes? + """ + if isinstance(col, Incomplete): + raise TypeError('attempt to pass an uninstanced column. Pass the column name as a string instead.') + elif isinstance(col, Column): + return col + elif isinstance(col, str): + return getattr(cls, col) + else: + raise TypeError + + +class AuthSrc(metaclass=ABCMeta): + ''' + AuthSrc object required for require_auth_base(). + + This is an abstract class and is NOT usable directly. + ''' + def required_exc(self) -> Never: + raise ValueError('required field missing') + def invalid_exc(self, msg: str = 'validation failed') -> Never: + raise ValueError(msg) + @abstractmethod + def get_session(self) -> Session: + pass + def get_user(self, getter: Callable): + return getter(self.get_token()) + @abstractmethod + def get_token(self): + pass + + +def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, value_src: Callable[[Callable[[], _T]], Any], session_src: Callable[[], Session], + column: str | Column[_T] = 'id', dest: str = 'user', required: bool = False, validators: Callable | Iterable[Callable] | None = None, + invalid_exc: Callable | None = None, required_exc: Callable | None = None): + ''' + Inject the current user into a view, given the Authorization: Bearer header. + + For portability reasons, this is a partial, two-component function. + + The value_src() callable takes a callable as its only and required argument, and the supplied + callable, when called, returns the value of the column. + + The session_src() callable takes no argument, and returns a Session object. + + XXX maybe a class is better than a thousand callables? + ''' + col = want_column(cls, column) + validators = makelist(validators) + + def get_user(token) -> DeclarativeBase: + if token is None: + return None + tok_parts = UserSigner.split_token(token) + user: HasSigner = session_src().execute(select(cls).where(col == tok_parts[0])).scalar() + try: + signer: UserSigner = user.signer() + signer.unsign(token) + return user + except Exception: + return None + + def _default_invalid(msg: str): + raise ValueError(msg) + + invalid_exc = invalid_exc or _default_invalid + required_exc = required_exc or (lambda: _default_invalid()) + + def decorator(func: Callable): + @wraps(func) + def wrapper(*a, **ka): + ka[dest] = value_src(get_user) + if not ka[dest] and required: + required_exc() + for valid in validators: + if not valid(ka[dest]): + invalid_exc(getattr(valid, 'message', 'validation failed').format(user=ka[dest])) + return func(*a, **ka) + return wrapper + return decorator + + __all__ = ( 'IdType', 'id_column', 'entity_base', 'declarative_base', 'token_signer', 'match_column', 'match_constraint', - 'author_pair', 'age_pair' + 'author_pair', 'age_pair', 'require_auth_base', 'want_column' ) \ No newline at end of file