elwig-backend: Add /auth endpoint
This commit is contained in:
		
							
								
								
									
										59
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										59
									
								
								README.md
									
									
									
									
									
								
							@@ -11,6 +11,65 @@ Authorization
 | 
				
			|||||||
-------------
 | 
					-------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
All API endpoints are secured via an HTTP basic authentication.
 | 
					All API endpoints are secured via an HTTP basic authentication.
 | 
				
			||||||
 | 
					Additionally, the `/auth` endpoint may be used to obtain a Bearer/JWT token.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### `GET /auth`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Example
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```http
 | 
				
			||||||
 | 
					GET /auth HTTP/1.1
 | 
				
			||||||
 | 
					Host: example.com
 | 
				
			||||||
 | 
					Accept: application/json
 | 
				
			||||||
 | 
					Authorization: Basic dGVzdDp0ZXN0
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```http
 | 
				
			||||||
 | 
					HTTP/1.1 200 OK
 | 
				
			||||||
 | 
					Content-Type: application/json; charset=UTF-8
 | 
				
			||||||
 | 
					Content-Length: 185
 | 
				
			||||||
 | 
					Access-Control-Allow-Origin: *
 | 
				
			||||||
 | 
					Access-Control-Allow-Headers: Authorization
 | 
				
			||||||
 | 
					Access-Control-Allow-Methods: HEAD, GET, POST, OPTIONS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### `POST /auth`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Example
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```http
 | 
				
			||||||
 | 
					POST /auth HTTP/1.1
 | 
				
			||||||
 | 
					Host: example.com
 | 
				
			||||||
 | 
					Accept: application/json
 | 
				
			||||||
 | 
					Content-Type: application/json
 | 
				
			||||||
 | 
					Content-Length: 44
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```http
 | 
				
			||||||
 | 
					POST /auth HTTP/1.1
 | 
				
			||||||
 | 
					Host: example.com
 | 
				
			||||||
 | 
					Accept: application/json
 | 
				
			||||||
 | 
					Content-Type: application/x-www-form-urlencoded
 | 
				
			||||||
 | 
					Content-Length: 31
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					username=test&password=password
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```http
 | 
				
			||||||
 | 
					HTTP/1.1 200 OK
 | 
				
			||||||
 | 
					Content-Type: application/json; charset=UTF-8
 | 
				
			||||||
 | 
					Content-Length: 185
 | 
				
			||||||
 | 
					Access-Control-Allow-Origin: *
 | 
				
			||||||
 | 
					Access-Control-Allow-Headers: Authorization
 | 
				
			||||||
 | 
					Access-Control-Allow-Methods: HEAD, GET, POST, OPTIONS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Base Data
 | 
				
			||||||
---------
 | 
					---------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### `GET /wine/varieties`
 | 
					### `GET /wine/varieties`
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,6 +6,7 @@ from typing import Callable, Optional
 | 
				
			|||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
 | 
					from http.server import BaseHTTPRequestHandler, HTTPServer
 | 
				
			||||||
import argparse
 | 
					import argparse
 | 
				
			||||||
import datetime
 | 
					import datetime
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
import traceback
 | 
					import traceback
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
import base64
 | 
					import base64
 | 
				
			||||||
@@ -13,15 +14,21 @@ import json
 | 
				
			|||||||
import sqlite3
 | 
					import sqlite3
 | 
				
			||||||
import urllib.parse
 | 
					import urllib.parse
 | 
				
			||||||
import gzip
 | 
					import gzip
 | 
				
			||||||
 | 
					import hashlib
 | 
				
			||||||
 | 
					import hmac
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
VERSION: str = '0.0.3'
 | 
					VERSION: str = '0.0.4'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CNX: sqlite3.Cursor
 | 
					CNX: sqlite3.Cursor
 | 
				
			||||||
USER_FILE: str
 | 
					USER_FILE: str
 | 
				
			||||||
 | 
					JWT_SECRET: bytes
 | 
				
			||||||
 | 
					JWT_ISSUER: str
 | 
				
			||||||
 | 
					JWT_INVALIDATE_BEFORE: int = 0
 | 
				
			||||||
 | 
					JWT_USER_INVALIDATE_BEFORE: dict[str, int] = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class HttpError(Exception):
 | 
					class HttpError(BaseException):
 | 
				
			||||||
    status_code: int
 | 
					    status_code: int
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, status_code: int, message: str):
 | 
					    def __init__(self, status_code: int, message: str):
 | 
				
			||||||
@@ -39,6 +46,11 @@ class UnauthorizedError(HttpError):
 | 
				
			|||||||
        super().__init__(401, message)
 | 
					        super().__init__(401, message)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ForbiddenError(HttpError):
 | 
				
			||||||
 | 
					    def __init__(self, message: str = 'Forbidden'):
 | 
				
			||||||
 | 
					        super().__init__(403, message)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NotFoundError(HttpError):
 | 
					class NotFoundError(HttpError):
 | 
				
			||||||
    def __init__(self, message: str = 'Not found'):
 | 
					    def __init__(self, message: str = 'Not found'):
 | 
				
			||||||
        super().__init__(404, message)
 | 
					        super().__init__(404, message)
 | 
				
			||||||
@@ -108,6 +120,19 @@ def jdmp(value, is_bool: bool = False) -> str:
 | 
				
			|||||||
    return json.dumps(value, ensure_ascii=False)
 | 
					    return json.dumps(value, ensure_ascii=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def check_password(stored_pwd: str, check_pwd: str) -> bool:
 | 
				
			||||||
 | 
					    return stored_pwd == check_pwd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def check_user_password(username: str, password: str) -> tuple[str, str]:
 | 
				
			||||||
 | 
					    with open(USER_FILE, 'r') as file:
 | 
				
			||||||
 | 
					        for line in file:
 | 
				
			||||||
 | 
					            (u, r, i, p) = line.strip().split(':', 3)
 | 
				
			||||||
 | 
					            if u == username and check_password(p, password):
 | 
				
			||||||
 | 
					                return u, r
 | 
				
			||||||
 | 
					    raise UnauthorizedError()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_delivery_schedule_filter_clauses(filters: list[Filter]) -> list[str]:
 | 
					def get_delivery_schedule_filter_clauses(filters: list[Filter]) -> list[str]:
 | 
				
			||||||
    clauses = []
 | 
					    clauses = []
 | 
				
			||||||
    for f in filters:
 | 
					    for f in filters:
 | 
				
			||||||
@@ -128,6 +153,9 @@ class ElwigApi(BaseHTTPRequestHandler):
 | 
				
			|||||||
        self.send_response(status_code)
 | 
					        self.send_response(status_code)
 | 
				
			||||||
        self.send_header('Access-Control-Allow-Origin', '*')
 | 
					        self.send_header('Access-Control-Allow-Origin', '*')
 | 
				
			||||||
        self.send_header('Access-Control-Allow-Headers', 'Authorization')
 | 
					        self.send_header('Access-Control-Allow-Headers', 'Authorization')
 | 
				
			||||||
 | 
					        if self.path in ('/auth',):
 | 
				
			||||||
 | 
					            self.send_header('Access-Control-Allow-Methods', 'HEAD, GET, POST, OPTIONS')
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
            self.send_header('Access-Control-Allow-Methods', 'HEAD, GET, OPTIONS')
 | 
					            self.send_header('Access-Control-Allow-Methods', 'HEAD, GET, OPTIONS')
 | 
				
			||||||
        if 300 <= status_code < 400 and status_code != 304 and url:
 | 
					        if 300 <= status_code < 400 and status_code != 304 and url:
 | 
				
			||||||
            self.send_header('Location', url)
 | 
					            self.send_header('Location', url)
 | 
				
			||||||
@@ -150,24 +178,72 @@ class ElwigApi(BaseHTTPRequestHandler):
 | 
				
			|||||||
    def see_other(self, url: str) -> None:
 | 
					    def see_other(self, url: str) -> None:
 | 
				
			||||||
        self.send(f'{{"url": {jdmp(url)}}}\n', status_code=303, url=url)
 | 
					        self.send(f'{{"url": {jdmp(url)}}}\n', status_code=303, url=url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def authorize(self) -> tuple[str, str]:
 | 
					    def authorize(self) -> tuple[str, str, str]:
 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
        auth = self.headers.get('Authorization')
 | 
					        auth = self.headers.get('Authorization')
 | 
				
			||||||
            if auth is None or not auth.startswith('Basic '):
 | 
					        if auth and auth.startswith('Basic '):
 | 
				
			||||||
 | 
					            u, r = ElwigApi.authorize_basic(auth[6:])
 | 
				
			||||||
 | 
					            return u, r, 'Basic'
 | 
				
			||||||
 | 
					        elif auth and auth.startswith('Bearer '):
 | 
				
			||||||
 | 
					            u, r = ElwigApi.authorize_bearer(auth[7:])
 | 
				
			||||||
 | 
					            return u, r, 'Bearer'
 | 
				
			||||||
        raise UnauthorizedError()
 | 
					        raise UnauthorizedError()
 | 
				
			||||||
            auth = base64.b64decode(auth[6:]).split(b':', 1)
 | 
					
 | 
				
			||||||
            if len(auth) != 2:
 | 
					    @staticmethod
 | 
				
			||||||
                raise UnauthorizedError('Invalid Authorization header')
 | 
					    def authorize_basic(auth: str) -> tuple[str, str]:
 | 
				
			||||||
            username, password = auth[0].decode('utf-8'), auth[1].decode('utf-8')
 | 
					        try:
 | 
				
			||||||
 | 
					            username, password = base64.b64decode(auth.strip() + '==').decode('utf-8').split(':', 1)
 | 
				
			||||||
        except:
 | 
					        except:
 | 
				
			||||||
            raise UnauthorizedError('Invalid Authorization header')
 | 
					            raise BadRequestError('Invalid Authorization header')
 | 
				
			||||||
        with open(USER_FILE, 'r') as file:
 | 
					        return check_user_password(username, password)
 | 
				
			||||||
            for line in file:
 | 
					
 | 
				
			||||||
                (u, r, p) = line.strip().split(':', 2)
 | 
					    @staticmethod
 | 
				
			||||||
                if u == username:
 | 
					    def authorize_bearer(token: str) -> tuple[str, str]:
 | 
				
			||||||
                    if p == password:
 | 
					        try:
 | 
				
			||||||
                        return u, r
 | 
					            hdr_r, payload_r, sig = token.strip().split('.')
 | 
				
			||||||
        raise UnauthorizedError()
 | 
					            hdr = json.loads(base64.urlsafe_b64decode(hdr_r + '==').decode('utf-8'))
 | 
				
			||||||
 | 
					            payload = json.loads(base64.urlsafe_b64decode(payload_r + '==').decode('utf-8'))
 | 
				
			||||||
 | 
					            if hdr['typ'] != 'JWT':
 | 
				
			||||||
 | 
					                raise ValueError()
 | 
				
			||||||
 | 
					            mac = hmac.new(JWT_SECRET, digestmod={
 | 
				
			||||||
 | 
					                'HS224': hashlib.sha224,
 | 
				
			||||||
 | 
					                'HS256': hashlib.sha256,
 | 
				
			||||||
 | 
					                'HS384': hashlib.sha384,
 | 
				
			||||||
 | 
					                'HS512': hashlib.sha512,
 | 
				
			||||||
 | 
					            }[hdr['alg']])
 | 
				
			||||||
 | 
					            mac.update((hdr_r + '.' + payload_r).encode('ascii'))
 | 
				
			||||||
 | 
					            digest = mac.digest()
 | 
				
			||||||
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            raise BadRequestError('Invalid Authorization header')
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            if digest != base64.urlsafe_b64decode(sig + '=='):
 | 
				
			||||||
 | 
					                raise UnauthorizedError('Invalid JWT signature')
 | 
				
			||||||
 | 
					            elif payload['iss'] != JWT_ISSUER:
 | 
				
			||||||
 | 
					                raise UnauthorizedError('Invalid JWT issuer')
 | 
				
			||||||
 | 
					            elif 'exp' in payload and payload['exp'] < int(time.time()):
 | 
				
			||||||
 | 
					                raise UnauthorizedError('JWT token expired')
 | 
				
			||||||
 | 
					            elif 'nbf' in payload and payload['nbf'] > int(time.time()):
 | 
				
			||||||
 | 
					                raise UnauthorizedError('JWT token not yet valid')
 | 
				
			||||||
 | 
					            elif payload['iat'] < JWT_INVALIDATE_BEFORE:
 | 
				
			||||||
 | 
					                raise UnauthorizedError('Invalidated JWT token')
 | 
				
			||||||
 | 
					            elif payload['iat'] < JWT_USER_INVALIDATE_BEFORE.get(payload['sub'], 0):
 | 
				
			||||||
 | 
					                raise UnauthorizedError('Invalidated JWT token')
 | 
				
			||||||
 | 
					            return payload['sub'], payload['rol']
 | 
				
			||||||
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            raise UnauthorizedError('Invalid JWT token')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def issue_jwt(username: str, role: str) -> str:
 | 
				
			||||||
 | 
					        hdr = base64.urlsafe_b64encode(b'{"typ":"JWT","alg":"HS256"}').strip(b'=')
 | 
				
			||||||
 | 
					        payload = base64.urlsafe_b64encode(json.dumps({
 | 
				
			||||||
 | 
					            'iss': JWT_ISSUER,
 | 
				
			||||||
 | 
					            'sub': username,
 | 
				
			||||||
 | 
					            'rol': role,
 | 
				
			||||||
 | 
					            'iat': int(time.time()),
 | 
				
			||||||
 | 
					        }, ensure_ascii=False, separators=(',', ':')).encode('utf-8')).strip(b'=')
 | 
				
			||||||
 | 
					        mac = hmac.new(JWT_SECRET, digestmod=hashlib.sha256)
 | 
				
			||||||
 | 
					        mac.update(hdr + b'.' + payload)
 | 
				
			||||||
 | 
					        sig = base64.urlsafe_b64encode(mac.digest()).strip(b'=')
 | 
				
			||||||
 | 
					        return (hdr + b'.' + payload + b'.' + sig).decode('ascii')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def exec_collection(self, sql_query: str, fmt: Callable, filters: list[Filter],
 | 
					    def exec_collection(self, sql_query: str, fmt: Callable, filters: list[Filter],
 | 
				
			||||||
                        offset: int = None, limit: int = None,
 | 
					                        offset: int = None, limit: int = None,
 | 
				
			||||||
@@ -304,7 +380,7 @@ class ElwigApi(BaseHTTPRequestHandler):
 | 
				
			|||||||
                self.send(OPEN_API_DOC)
 | 
					                self.send(OPEN_API_DOC)
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            username, role = self.authorize()
 | 
					            username, role, auth_method = self.authorize()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            parts = self.path.split('?', 1)
 | 
					            parts = self.path.split('?', 1)
 | 
				
			||||||
            if len(parts) == 1:
 | 
					            if len(parts) == 1:
 | 
				
			||||||
@@ -320,7 +396,11 @@ class ElwigApi(BaseHTTPRequestHandler):
 | 
				
			|||||||
                raise BadRequestError('Invalid integer value in query')
 | 
					                raise BadRequestError('Invalid integer value in query')
 | 
				
			||||||
            order = query['order'] if 'order' in query else None
 | 
					            order = query['order'] if 'order' in query else None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if path == '/wine/varieties':
 | 
					            if path == '/auth':
 | 
				
			||||||
 | 
					                if auth_method == 'Bearer':
 | 
				
			||||||
 | 
					                    raise ForbiddenError('Tokens must not be renewed')
 | 
				
			||||||
 | 
					                self.send( f'{{"token":{jdmp(ElwigApi.issue_jwt(username, role))}}}\n')
 | 
				
			||||||
 | 
					            elif path == '/wine/varieties':
 | 
				
			||||||
                self.exec_collection(
 | 
					                self.exec_collection(
 | 
				
			||||||
                    "SELECT sortid, type, name, comment FROM wine_variety",
 | 
					                    "SELECT sortid, type, name, comment FROM wine_variety",
 | 
				
			||||||
                    lambda r: f'{{"sortid":{jdmp(r[0])},"type":{jdmp(r[1])},"name":{jdmp(r[2])},"comment":{jdmp(r[3])}}}',
 | 
					                    lambda r: f'{{"sortid":{jdmp(r[0])},"type":{jdmp(r[1])},"name":{jdmp(r[2])},"comment":{jdmp(r[3])}}}',
 | 
				
			||||||
@@ -355,21 +435,77 @@ class ElwigApi(BaseHTTPRequestHandler):
 | 
				
			|||||||
            traceback.print_exception(e)
 | 
					            traceback.print_exception(e)
 | 
				
			||||||
            self.error(500, str(e))
 | 
					            self.error(500, str(e))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def do_POST(self) -> None:
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            parts = self.path.split('?', 1)
 | 
				
			||||||
 | 
					            path = parts[0]
 | 
				
			||||||
 | 
					            if path == '/auth':
 | 
				
			||||||
 | 
					                content_type = [p.strip() for p in self.headers.get('Content-Type', '').split(';')][0]
 | 
				
			||||||
 | 
					                content_len = self.headers.get('Content-Length')
 | 
				
			||||||
 | 
					                if content_len is None:
 | 
				
			||||||
 | 
					                    raise HttpError(411, 'Length required')
 | 
				
			||||||
 | 
					                content_len = int(content_len)
 | 
				
			||||||
 | 
					                if content_len > 4096 or content_len < 0:
 | 
				
			||||||
 | 
					                    raise HttpError(413, 'Content too large')
 | 
				
			||||||
 | 
					                elif content_type == 'application/x-www-form-urlencoded':
 | 
				
			||||||
 | 
					                    payload = self.rfile.read(content_len)
 | 
				
			||||||
 | 
					                    try:
 | 
				
			||||||
 | 
					                        data = {urllib.parse.unquote(s[0]): urllib.parse.unquote(s[-1])
 | 
				
			||||||
 | 
					                                for s in [p.split(b'=', 1) for p in payload.split(b'&')]}
 | 
				
			||||||
 | 
					                        username, password = data['username'], data['password']
 | 
				
			||||||
 | 
					                    except Exception:
 | 
				
			||||||
 | 
					                        raise BadRequestError('Invalid URL encoded payload')
 | 
				
			||||||
 | 
					                elif content_type == 'application/json':
 | 
				
			||||||
 | 
					                    payload = self.rfile.read(content_len)
 | 
				
			||||||
 | 
					                    try:
 | 
				
			||||||
 | 
					                        data = json.loads(payload.decode('utf-8'))
 | 
				
			||||||
 | 
					                        username, password = data['username'], data['password']
 | 
				
			||||||
 | 
					                    except Exception:
 | 
				
			||||||
 | 
					                        raise BadRequestError('Invalid JSON object')
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    raise HttpError(415, 'Unsupported media type')
 | 
				
			||||||
 | 
					                username, role = check_user_password(username, password)
 | 
				
			||||||
 | 
					                self.send(f'{{"token":{jdmp(ElwigApi.issue_jwt(username, role))}}}\n')
 | 
				
			||||||
 | 
					            elif path in ('/wine/varieties', '/wine/quality_levels', '/wine/attributes', '/wine/cultivations', '/modifiers', '/delivery_schedules'):
 | 
				
			||||||
 | 
					                raise MethodNotAllowedError()
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                raise NotFoundError('Invalid path')
 | 
				
			||||||
 | 
					        except HttpError as e:
 | 
				
			||||||
 | 
					            self.error(e.status_code, str(e))
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            traceback.print_exception(e)
 | 
				
			||||||
 | 
					            self.error(500, str(e))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def main() -> None:
 | 
					def main() -> None:
 | 
				
			||||||
    global CNX
 | 
					    global CNX
 | 
				
			||||||
    global USER_FILE
 | 
					    global USER_FILE
 | 
				
			||||||
 | 
					    global JWT_ISSUER
 | 
				
			||||||
 | 
					    global JWT_SECRET
 | 
				
			||||||
 | 
					    global JWT_INVALIDATE_BEFORE
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    sqlite3.register_adapter(datetime.date, lambda d: d.strftime('%Y-%m-%d'))
 | 
					    sqlite3.register_adapter(datetime.date, lambda d: d.strftime('%Y-%m-%d'))
 | 
				
			||||||
    sqlite3.register_adapter(datetime.time, lambda t: t.strftime('%H:%M:%S'))
 | 
					    sqlite3.register_adapter(datetime.time, lambda t: t.strftime('%H:%M:%S'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    parser = argparse.ArgumentParser()
 | 
					    parser = argparse.ArgumentParser()
 | 
				
			||||||
    parser.add_argument('db', type=str, metavar='DB')
 | 
					    parser.add_argument('db', type=str, metavar='DB')
 | 
				
			||||||
 | 
					    parser.add_argument('jwt_file', type=str, metavar='JWT-FILE')
 | 
				
			||||||
    parser.add_argument('user_file', type=str, metavar='USER-FILE')
 | 
					    parser.add_argument('user_file', type=str, metavar='USER-FILE')
 | 
				
			||||||
    parser.add_argument('-p', '--port', type=int, default=8080)
 | 
					    parser.add_argument('-p', '--port', type=int, default=8080)
 | 
				
			||||||
    args = parser.parse_args()
 | 
					    args = parser.parse_args()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    jwt_file = args.jwt_file
 | 
				
			||||||
 | 
					    with open(jwt_file, 'rb') as file:
 | 
				
			||||||
 | 
					        iss, dt, JWT_SECRET = file.readline().strip().split(b':', 2)
 | 
				
			||||||
 | 
					        JWT_ISSUER = iss.decode('utf-8')
 | 
				
			||||||
 | 
					        JWT_INVALIDATE_BEFORE = int(datetime.datetime.fromisoformat(dt.decode('ascii')).timestamp()) if dt else 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    USER_FILE = args.user_file
 | 
					    USER_FILE = args.user_file
 | 
				
			||||||
 | 
					    with open(USER_FILE, 'r') as file:
 | 
				
			||||||
 | 
					        for line in file:
 | 
				
			||||||
 | 
					            (u, r, i, p) = line.strip().split(':', 3)
 | 
				
			||||||
 | 
					            JWT_USER_INVALIDATE_BEFORE[u.strip()] = int(datetime.datetime.fromisoformat(i).timestamp()) if i else 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    CNX = sqlite3.connect(f'file:{args.db}?mode=ro', uri=True)
 | 
					    CNX = sqlite3.connect(f'file:{args.db}?mode=ro', uri=True)
 | 
				
			||||||
    CNX.create_function('REGEXP', 2, sqlite_regexp, deterministic=True)
 | 
					    CNX.create_function('REGEXP', 2, sqlite_regexp, deterministic=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -396,9 +532,65 @@ OPEN_API_DOC: str = '''{
 | 
				
			|||||||
    "url": "https://wgm.elwig.at/elwig/api/v1",
 | 
					    "url": "https://wgm.elwig.at/elwig/api/v1",
 | 
				
			||||||
    "description": "WG Matzen"
 | 
					    "description": "WG Matzen"
 | 
				
			||||||
  }],
 | 
					  }],
 | 
				
			||||||
  "components": {"securitySchemes": {"basicAuth": {"type": "http", "scheme": "basic"}}},
 | 
					  "components": {
 | 
				
			||||||
  "security": [{"basicAuth": []}],
 | 
					    "securitySchemes": {
 | 
				
			||||||
 | 
					      "basicAuth": {"type": "http", "scheme": "basic"},
 | 
				
			||||||
 | 
					      "bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "security": [{"basicAuth": []}, {"bearerAuth": []}],
 | 
				
			||||||
  "paths": {
 | 
					  "paths": {
 | 
				
			||||||
 | 
					    "/auth": {
 | 
				
			||||||
 | 
					      "get": {
 | 
				
			||||||
 | 
					        "tags": ["Authentication"],
 | 
				
			||||||
 | 
					        "summary": "Authentication",
 | 
				
			||||||
 | 
					        "security": [{"basicAuth": []}],
 | 
				
			||||||
 | 
					        "responses": {
 | 
				
			||||||
 | 
					          "200": {
 | 
				
			||||||
 | 
					            "description": "Success",
 | 
				
			||||||
 | 
					            "content": {
 | 
				
			||||||
 | 
					              "application/json": {
 | 
				
			||||||
 | 
					                "schema": {"type": "object", "required": ["token"], "properties": {"token": {"type": "string"}}},
 | 
				
			||||||
 | 
					                "examples": {"simple": {"value": {"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJleGFtcGxlLmNvbSIsInN1YiI6InRlc3QiLCJyb2wiOiJleHRlcm5hbCIsImlhdCI6MTc0NTgzMTQzN30.VMLz20aWI8nSd7ocT2W750Cy80OJs8OMiQCtq5Df0rE"}}}
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          "400": {"description": "Bad Request",           "content": {"application/json": {"schema": {"type": "object", "required": ["message"], "properties": {"message": {"oneOf": [{"type": "string"}, {"type": "null"}]}}}, "examples": {"simple": {"value": {"message": "Invalid Authorization header"}}}}}},
 | 
				
			||||||
 | 
					          "401": {"description": "Unauthorized",          "content": {"application/json": {"schema": {"type": "object", "required": ["message"], "properties": {"message": {"oneOf": [{"type": "string"}, {"type": "null"}]}}}, "examples": {"simple": {"value": {"message": "Unauthorized"}}}}}},
 | 
				
			||||||
 | 
					          "500": {"description": "Internal Server Error", "content": {"application/json": {"schema": {"type": "object", "required": ["message"], "properties": {"message": {"oneOf": [{"type": "string"}, {"type": "null"}]}}}, "examples": {"simple": {"value": {"message": "Unknown error"}}}}}}
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "post": {
 | 
				
			||||||
 | 
					        "tags": ["Authentication"],
 | 
				
			||||||
 | 
					        "summary": "Authentication",
 | 
				
			||||||
 | 
					        "security": [],
 | 
				
			||||||
 | 
					        "requestBody": {
 | 
				
			||||||
 | 
					          "required": true,
 | 
				
			||||||
 | 
					          "content": {
 | 
				
			||||||
 | 
					            "application/json": {
 | 
				
			||||||
 | 
					                "schema": {"type": "object", "required": ["username", "password"], "properties": {"username": {"type": "string"}, "password": {"type": "string"}}}
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            "application/x-www-form-urlencoded": {
 | 
				
			||||||
 | 
					               "schema": {"type": "object", "required": ["username", "password"], "properties": {"username": {"type": "string"}, "password": {"type": "string"}}}
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "responses": {
 | 
				
			||||||
 | 
					          "200": {
 | 
				
			||||||
 | 
					            "description": "Success",
 | 
				
			||||||
 | 
					            "content": {
 | 
				
			||||||
 | 
					              "application/json": {
 | 
				
			||||||
 | 
					                "schema": {"type": "object", "required": ["token"], "properties": {"token": {"type": "string"}}},
 | 
				
			||||||
 | 
					                "examples": {"simple": {"value": {"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJleGFtcGxlLmNvbSIsInN1YiI6InRlc3QiLCJyb2wiOiJleHRlcm5hbCIsImlhdCI6MTc0NTgzMTQzN30.VMLz20aWI8nSd7ocT2W750Cy80OJs8OMiQCtq5Df0rE"}}}
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          "400": {"description": "Bad Request",           "content": {"application/json": {"schema": {"type": "object", "required": ["message"], "properties": {"message": {"oneOf": [{"type": "string"}, {"type": "null"}]}}}, "examples": {"simple": {"value": {"message": "Invalid Authorization header"}}}}}},
 | 
				
			||||||
 | 
					          "401": {"description": "Unauthorized",          "content": {"application/json": {"schema": {"type": "object", "required": ["message"], "properties": {"message": {"oneOf": [{"type": "string"}, {"type": "null"}]}}}, "examples": {"simple": {"value": {"message": "Unauthorized"}}}}}},
 | 
				
			||||||
 | 
					          "500": {"description": "Internal Server Error", "content": {"application/json": {"schema": {"type": "object", "required": ["message"], "properties": {"message": {"oneOf": [{"type": "string"}, {"type": "null"}]}}}, "examples": {"simple": {"value": {"message": "Unknown error"}}}}}}
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "/wine/varieties": {
 | 
					    "/wine/varieties": {
 | 
				
			||||||
      "get": {
 | 
					      "get": {
 | 
				
			||||||
        "tags": ["Base Data"],
 | 
					        "tags": ["Base Data"],
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user