Work In Progress — usable for development, testing, and controlled internal evaluation.
Pre-release status: Flask-AAS is under active development. Review the documented configuration and deployment guidance before use.
Flask-AAS is a modular Flask-based authentication, auditing, and application-host foundation with built-in user management, session security, log tracking, optional abuse prevention, and an opt-in first-class application-plugin system. It is designed to stay understandable for small projects while providing a reusable security foundation for larger applications.
Flask Auth & Audit System began as authentication code written for Open Auto Classifieds more than a decade ago. As that code aged and my requirements changed, I decided to rebuild it in Flask rather than continue extending the original implementation.
Flask-AAS is built primarily to provide a consistent foundation for my own projects, but is published openly for anyone who finds it useful.
This project focuses on:
- Keeping external dependencies minimal
- Providing practical features that work out of the box
- Leaving optional integrations and extras up to you
- Hosting application-specific functionality behind a small, explicit plugin contract
- Staying adaptable for both small projects and larger ones
| Document | Purpose |
|---|---|
docs/deployment-modes.md |
Development versus deployed behavior |
docs/security-checklist.md |
Reusable route-review checklist |
docs/security-tooling.md |
Static analysis, dependency audit, and CI baseline |
docs/plugin-troubleshooting.md |
Plugin discovery, persisted registry, migration, reload, and recovery troubleshooting |
The base is intentionally designed to remain easy to run locally. Direct HTTP, generated development secrets, SQLite, and in-memory services are valid development choices. Stricter requirements apply only when the selected deployment mode needs them.
- Secure login with Flask-Login
- Password hashing via bcrypt, with full long-password handling enabled
- Admin-managed password policy enabled by default with a 20-character minimum, passphrase-friendly composition defaults, and deployment values used only to seed a fresh database
- Optional provider-backed new-password checking, disabled by default, with a built-in local common-password blocklist and a registration seam for additional providers
- Active session tracking
- Sliding authenticated-session inactivity timeout; ordinary sessions require a full login after expiry
- Remembered sessions survive inactivity only as non-fresh authentication, preserving the remember cookie while requiring reauthentication for fresh-login-protected actions
- Remember-cookie-restored sessions begin a new non-fresh inactivity window
- Account state flags:
activated→ Email verification statusapproved→ Optional admin review
- Role-based access control (RBAC)
- Flexible registration fields (company, phone, location, etc.)
- Self-service account profile editing with canonical host profile-image upload/replace/remove using the existing
User.imageidentity field - Shared ISO 3166-1 country and ISO 3166-2 subdivision catalog for consistent host/plugin location fields
- Admin panel with settings management
- Single-user lockdown mode
- Global CSRF protection
- Uses the existing
User.imagefield; no additional profile-image table is required. Admin → Site Settings → User Storage Pathis the complete local storage directory.- Relative storage paths resolve from the Flask application root; absolute paths are used as configured.
- Fresh installs default to
static/images/users, which resolves toapp/static/images/users. - Uploaded JPEG, PNG, or WebP files are decoded/validated, EXIF-oriented, center-cropped to 256x256, metadata-stripped, and re-encoded as WebP with a generated random filename.
- Upload/replace/remove are authenticated, CSRF-protected, rate-limited operations with transaction-aware file cleanup.
- Administrators can immediately remove an unacceptable custom profile image from Admin → View Users; the database reference is committed before best-effort file deletion, and users without a custom image do not expose a meaningless removal action.
- Flask-AAS does not currently publish a generic profile-image/media route. Account/admin pages render the stored generated WebP internally; future application plugins decide whether a canonical host profile image is relevant or visible in their own domain.
- Domain media such as AutoGrid360 listing photos remains plugin-owned. S3/object-storage support is a later concern if real host profile-image usage requires it.
Password checking is disabled by default. When enabled in Admin → Site Settings, every new-password workflow sends the candidate through the selected provider after the normal local policy checks. The built-in local provider uses the packaged common-password blocklist and performs no network requests.
Additional implementations extend PasswordCheckProvider and register through register_password_check_provider(). The password-setting routes do not need provider-specific code; registered providers become available to the Site Settings provider selector.
- TOTP enrollment and authenticator replacement
- Fresh reauthentication before MFA setup, replacement, recovery-code rotation, or disable
- Current-TOTP or unused recovery-code confirmation for sensitive MFA changes
- Hashed, display-once, single-use recovery codes with explicit rotation
- Bounded pending-MFA state and lockout handling
- Forced complete login with remember-cookie deletion after terminal MFA failures
- Persistent TOTP-counter replay protection
- Cryptographically random password-reset secrets stored only as SHA-256 hashes
- Durable expiry, consumption, and revocation state
- Atomic one-time token consumption with replay rejection
- Revocation of every outstanding reset link after a successful password change
- Authentication-version rotation that invalidates active sessions and remember cookies
- Password-change security notification after the database transaction commits
- Optional email verification using the persisted
activatedaccount state - Idempotent verification links with safe handling for malformed, expired, missing-account, and already-used tokens
- Asynchronous mail dispatch so HTTP requests do not wait for SMTP delivery
- Explicit dispatch results:
queued,disabled, orfailed - Deployment-managed SMTP with an optional encrypted Site Settings override
- Runtime display of the active mail source: Debug, Site Settings, Environment, Disabled, or Not configured
- Global Enable Application Plugins switch; Flask-AAS operates normally with the plugin host disabled
- Database-backed registration automatically hydrated from
app/plugins/*/plugin.tomlmanifests - Canonical package metadata in
plugin.toml, inspectable without importing plugin implementation Python - Explicit per-application enable/disable plus derived configuration and runtime state
- Metadata-only registration: a registered but disabled application does not import its implementation/models, deploy plugin schema, register routes, or contribute navigation during ordinary startup
- Explicit Enable trust/code-execution boundary; enabling does not silently create or migrate plugin schema
- Independent plugin-owned Alembic histories declared by the manifest, with
plugin_<id>_*table ownership andplugin_<id>_alembic_versionversion tables - Fail-closed
NEEDS_MIGRATION,NEEDS_CONFIGURATION,INCOMPATIBLE,ACTIVE, andERRORruntime states - Admin Upgrade Database Schema action for an enabled compatible plugin that is migration-pending; the browser path upgrades only to
head - Reload App Config applies structural/runtime-snapshot changes through a fresh Gunicorn worker instead of mutating Flask Blueprints in a running process; already-loaded route/navigation access still follows the current persisted
enabled/configuredgate - Immediate request/navigation denial after disable, followed by structural removal on reload
- Plugin CLI dispatch through
python manage.py plugin run <plugin_id> ...; manifest-declared migrations receive host-owneddbcommands automatically while configuration/maintenance commands remain plugin-owned - Host-owned navigation integration and host theme/template inheritance for plugin pages
- Plugin-owned ordinary configuration and persistence; disabling preserves business data/schema while clearing plugin-managed persisted secrets
- Versioned
PLUGIN_API_VERSION = 1compatibility boundary - Route-level authorization remains plugin-owned: plugin routes may be public, require the normal Flask-AAS login context, use existing host roles for coarse admission, or apply plugin-owned domain authorization
Application plugin directories are deployment-supplied native Python code. Filesystem presence automatically registers validated static plugin.toml metadata with the application disabled; enabling a plugin is the explicit decision to trust that code to run with the permissions of the Flask-AAS process. Flask-AAS does not claim to sandbox in-process Python plugins.
The human-facing reference implementation is maintained separately in flask-aas-example-plugin and may be placed under app/plugins/example/ like any other trusted deployment-supplied plugin. Flask-AAS keeps only a synthetic plugin fixture under tests/fixtures/plugin_app/ so host contract tests remain self-contained. The reference plugin demonstrates public/authenticated/admin surfaces, plugin-owned configuration and persistence, independent migrations, navigation, CLI dispatch, and basic admin CRUD without adding a separate application-entitlement subsystem. YATSEE completed the first substantial migration proof, and AutoGrid360 completed the first substantial clean-slate consumer validation of Plugin API v1.
- The selected Flask-AAS theme owns generic typography, forms, buttons, panels, tables, pagination, layout, focus/accessibility behavior, and the main shell.
- Plugin pages inherit the host theme through
templates/plugins/base.html; plugin CSS is additive and should remain domain-specific. - The host admin shell intentionally remains separate where it has admin-only behavior, but it shares the same stylesheet and one canonical Admin Menu partial.
- Authenticated destination navigation is sidebar-owned: the top header no longer duplicates Dashboard or Admin links, while Logout remains available as the global session action.
- The Admin homepage provides a compact Overview plus direct User Accounts, Site Settings, and Applications destinations using existing route data only.
- The account page keeps identity/avatar presentation and image controls together, uses a semantic read-only account summary beside Active Sessions, and keeps editable account fields in their own form section.
- Current admin JavaScript is first-party/static and remains compatible with the nonce-based CSP; location lookup also enforces same-origin use.
- HTML response minification preserves explicit document structure and closing tags. The PageGen timing comment is injected after minification while ordinary template comments remain stripped.
- Tracks the submitted username or email identifier
- Records IPv4 or IPv6 address, user agent, referrer, and timestamp
- Stores the final authentication outcome rather than password-match status
- Uses normalized internal failure reasons without changing enumeration-resistant public responses
- Remains separate from general application activity auditing
- Tracks actor, action, target, client address, and route-selected metadata
- Stores structured metadata through one portable JSON serialization boundary
- Commits business-success events with the business transaction
- Uses isolated writes for standalone views, denials, failures, and operational tracking
- Supports explicit per-route redaction for token-bearing URL parameters
- Future: filtering, export, retention controls, and analytics
Modular, pluggable, and fully optional.
- Blocks brute-force attempts based on:
- IP address
- Username
- Threshold example: 10 failures in 5 minutes
- Automatic cooldown resets
- Configurable timers and limits
- Admin/internal service exemptions
- Supports audit logging for lockouts and failed attempts
- Application-level limits use Flask-Limiter; an edge proxy or WAF is optional.
- Current route examples:
- Login:
10 / minute - Registration:
5 / hour - Password-reset request:
10 / hour - Password-reset submission:
5 / minute - CAPTCHA:
10 / minutewith50 / 5 minutesburst control
- Login:
- Account and administrative routes also use route-specific limits.
- Configure client-IP trust and shared rate-limit storage for the selected deployment topology.
/sitemap.xml→ excludes protected/internal routes and unavailable optional routes/robots.txt→ references sitemap- The sitemap is generated dynamically so feature-toggle changes take effect immediately
| Endpoint | Methods | Rule |
|---|---|---|
| about.about | GET | /about |
| account.account | GET, POST | /account |
| account.remove_profile_image | POST | /account/profile-image/remove |
| account.revoke_other_sessions | POST | /account/sessions/revoke-others |
| account.revoke_session | POST | /account/sessions/<int:session_id>/revoke |
| account.upload_profile_image | POST | /account/profile-image |
| admin.admin_home | GET | /admin/ |
| captcha.captcha_image | GET | /captcha_image |
| contact.contact | GET, POST | /contact |
| dashboard.dashboard | GET | /dashboard |
| favicon.favicon | GET | /favicon.ico |
| index.index | GET | / |
| locations.zones | GET | /reference/zones |
| login.login | GET, POST | /login |
| logout.logout | GET | /logout |
| mfa.mfa_disable | GET, POST | /mfa/disable |
| mfa.mfa_reauth | GET, POST | /mfa/reauth |
| mfa.mfa_recovery_codes | GET, POST | /mfa/recovery-codes |
| mfa.mfa_replace | GET, POST | /mfa/replace |
| mfa.mfa_setup | GET, POST | /mfa/setup |
| mfa.mfa_verify | GET, POST | /mfa/verify |
| plugins.disable | POST | /admin/plugins/<int:registration_id>/disable |
| plugins.enable | POST | /admin/plugins/<int:registration_id>/enable |
| plugins.list_plugins | GET | /admin/plugins/ |
| plugins.reload_config | POST | /admin/plugins/reload |
| plugins.run_dataset_action | POST | /admin/plugins/<int:registration_id>/datasets/<string:dataset_key>/run |
| plugins.upgrade_schema | POST | /admin/plugins/<int:registration_id>/upgrade-schema |
| privacy.privacy | GET | /privacy |
| register.register | GET, POST | /register |
| reset.change_password | GET, POST | /change-password |
| reset.forgot_password | GET, POST | /forgot-password |
| reset.reset_password | GET, POST | /reset-password/<token> |
| reset.set_password | GET, POST | /set-password/<token> |
| robots.robots | GET | /robots.txt |
| settings.settings | GET, POST | /admin/settings/ |
| sitemap.sitemap | GET | /sitemap.xml |
| static | GET | /static/<path:filename> |
| tos.tos | GET | /tos |
| users.delete_user | POST | /admin/users/<int:user_id>/delete |
| users.edit_user | GET, POST | /admin/users/<int:user_id>/edit |
| users.list_users | GET | /admin/users/ |
| users.remove_profile_image | POST | /admin/users/<int:user_id>/profile-image/remove |
| verify.verify_email_token | GET | /email/<token> |
Application-plugin routes are intentionally omitted from this static core-route table because their structural registration depends on the persisted plugin state at worker startup. The host request guard independently denies plugin endpoints that are not effectively usable.
Flask-AAS uses Flask-Migrate (Alembic) with SQLAlchemy. During the current pre-release phase, generated migration directories are intentionally ignored and are not part of the supported upgrade contract. A clean local or initial deployment may generate its own migration state.
Initialize a clean development database
python manage.py db init
python manage.py db migrate -m "Initial migration"
python manage.py db upgrade
python manage.py seed-dbCountry and subdivision reference data is vendored under app/data/ from the iso-codes project. seed-db synchronizes ISO 3166-1 countries and ISO 3166-2 zones, including subdivision type and parent hierarchy. To refresh the vendored snapshot from a locally installed iso-codes package:
python scripts/update_iso_reference.pyThe default source directory is /usr/share/iso-codes/json; use --source-dir when your distribution installs the files elsewhere.
After local model changes
python manage.py db migrate -m "Describe change"
python manage.py db upgradeThis is acceptable only while Flask-AAS is pre-release and deployments are treated as clean installs. The host bootstrap boundary is established, but durable in-place core release upgrades are not yet claimed.
Application plugins have a separate migration boundary. A plugin manifest may declare a package-local migration environment, for example:
[plugin]
id = "your_plugin"
name = "Your Plugin"
navigation_label = "Your Plugin"
entrypoint = "app.plugins.your_plugin.plugin:plugin"
migrations = "migrations"For a plugin with declared migrations:
- Enable is permission to execute the selected trusted plugin, not permission to mutate its schema;
- an enabled plugin whose schema is behind reports
NEEDS_MIGRATIONand is not structurally registered for normal application use; - a fresh plugin namespace may create the current plugin-owned model tables and stamp the current migration head;
- an existing versioned plugin runs its own Alembic history;
- existing unversioned
plugin_<id>_*tables fail closed rather than being blindly stamped; - plugin history uses its own
plugin_<id>_alembic_versiontable and must not own Flask-AAS core tables.
When a manifest declares migrations, Flask-AAS automatically supplies the
standard plugin migration CLI; the plugin does not implement or register these
commands itself. Initializing an Alembic environment and generating a revision
are plugin source-development operations. A published plugin normally ships its
migration environment/history, so an operator ordinarily inspects or upgrades it:
python manage.py plugin run your_plugin db current
python manage.py plugin run your_plugin db upgradePlugin authors may additionally use the development commands when creating or testing schema history:
python manage.py plugin run your_plugin db init
python manage.py plugin run your_plugin db migrate -m "Describe plugin schema change"
python manage.py plugin run your_plugin db downgradeThe top-level db command is reserved by Flask-AAS for any plugin whose
manifest declares migrations. New plugins gain this lifecycle automatically
from plugin.toml without adding plugin-specific migration commands to core.
Development migration history remains disposable before the first supported release/checkpoint. Published schema checkpoints become durable upgrade origins. AAS-039 is complete: manifest-driven plugin migration ownership, independent version tables, migration-aware runtime state, browser/CLI schema management, and core/plugin Alembic ownership isolation are established. Release-grade historical N -> N+1, failed-migration, greenfield/history-equivalence, and focused PostgreSQL migration QA remain broader AAS-031 regression work when real released migration history exists.
The current tested runtime is Python 3.13.13. Generate the lock and run the application with Python 3.13 so environment markers and binary-wheel selection match the deployment image.
git clone https://github.com/alias454/flask-aas.git
cd flask-aas
python3.13 -m venv .venv
source .venv/bin/activate # Linux/macOS
python -m pip install --require-hashes -r requirements.txt
cp .env_example .env
export FLASK_APP=app
flask runpyproject.toml is the human-maintained source for direct runtime dependencies. requirements.txt is a generated, fully pinned, hash-verified deployment lock and should not be edited manually.
The current lock baseline was generated on Fedora 42 Linux x86_64 with Python 3.13.13, pip 26.1.2, and pip-tools 7.6.0. It was validated in the python:3.13.13-slim-trixie container using binary wheels only.
Regenerate the lock from a clean Python 3.13 environment:
./scripts/lock.shThe lock workflow uses pip-tools; deployment still requires only standard pip and requirements.txt. JWT support uses PyJWT, password hashing and verification use the single Flask-Bcrypt stack, and cryptography is a direct dependency for encrypted runtime SMTP credentials.
Outbound email uses an explicit master switch in Admin → Site Settings:
Enable Outbound Email
Require Email Verification
Require Email Verification can be enabled only when outbound email is enabled and an effective mail transport is available. The application does not claim delivery merely because a message was queued. Final SMTP success or failure is logged by the asynchronous worker.
The public contact form is disabled by default. An administrator can enable it
only when Admin Email is configured and outbound email has an effective
transport. If any dependency later becomes unavailable, /contact returns 404,
the Contact navigation link is hidden, and the route is omitted from the sitemap.
Contact submissions also pass through the selected spam-check provider when spam
checking is enabled. Fresh installs preserve the existing local protection by
enabling the built-in local provider, which reads a packaged phrase list and
performs no network requests. Provider selection is managed in Admin → Site
Settings so a later Akismet, ML, or downstream-specific worker can implement the
same pass/fail contract without modifying the contact route. Provider runtime
failures are logged and fail open so an optional spam service cannot take the
contact form offline.
Deployment SMTP settings are supplied through .env or another external configuration source:
MAIL_DEBUG=false
MAIL_SERVER=smtp.example.com
MAIL_PORT=587
MAIL_USE_TLS=true
MAIL_USE_SSL=false
MAIL_USERNAME=mailer@example.com
MAIL_PASSWORD=replace-me
MAIL_DEFAULT_SENDER=mailer@example.comRuntime SMTP editing is disabled by default. To permit an administrator-managed override:
MAIL_CONFIG_UI_ENABLED=true
MAIL_CONFIG_ENCRYPTION_KEY=<fernet-key>Generate a Fernet key with:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"Test the active outbound email configuration from the operator CLI. The
configured Admin Email is used by default; --to provides a one-time override:
python manage.py mail-test
python manage.py mail-test --to operator@example.comThe command exits unsuccessfully when outbound email is disabled, unavailable, or cannot be queued.
Effective source precedence is:
- Enable Outbound Email off → delivery disabled.
MAIL_DEBUG=true→ mock delivery; no SMTP connection.- Complete Site Settings SMTP configuration → encrypted runtime override.
- Complete deployment SMTP configuration → environment fallback.
- No complete source → delivery unavailable.
The two SMTP sources are never blended field by field. A partial Site Settings override is rejected. The saved SMTP password is encrypted with the externally supplied key, is never rendered back into the form, and is cleared only through the explicit override-clear control.
Run the email lifecycle, transport-resolution, encryption, settings-route, and account-state regression suites with:
python -m pytest \
tests/test_mailer.py \
tests/test_mail_config.py \
tests/test_email_lifecycle.pyRun the audit transaction, metadata-redaction, tracking, login-outcome, and MFA lifecycle regression suites with:
python -m pytest \
tests/test_audit_tracking.py \
tests/test_login_audit.pyRun the password-policy, password-provider, contact-route, and spam-provider regression suites with:
python -m pytest \
tests/test_password_policy.py \
tests/test_password_check.py \
tests/test_contact.py \
tests/test_spam_check.pyRun the Plugin API/lifecycle/reference-application suites with:
python -m pytest \
tests/test_plugin_contract.py \
tests/test_plugin_manifest.py \
tests/test_plugin_migrations.py \
tests/test_plugin_lifecycle.py \
tests/test_plugin_admin.py \
tests/test_plugin_web_surface.py \
tests/test_plugin_integration_surfaces.py \
tests/test_plugin_bundled.py \
tests/test_plugin_reload.py \
tests/test_plugin_fixture_persistence.pyRun the complete regression suite with:
python -m pytestTests use SQLite by default. Selected audit/account lifecycle suites can also target a disposable PostgreSQL database through the documented test database environment variables so the portable transaction and schema behavior can be exercised on both backends.
Run the host account-profile, response-minification, theme, and administrative UI contract coverage with:
python -m pytest \
tests/test_account_profile.py \
tests/test_admin_avatar.py \
tests/test_admin_ui_contract.py \
tests/test_response_minify.py \
tests/test_theme_contract.pyThe most recent confirmed complete host run is:
374 passed, 13 warnings, 22 subtests passed in 69.03s
- Build the Docker image (no cache)
docker build --pull --no-cache -t flask-aas:local .- Run the container
docker run -d --env-file .env -p 5000:5000 --name flask-aas flask-aas:local
docker run --rm -it --env-file .env -p 5000:5000 flask-aas:local- Access the app
Open your browser and go to http://localhost:5000
The plugin host is disabled by default. After trusted plugin source has been placed under app/plugins/<id>/ and its manifest has been discovered/registered disabled, the normal activation flow is:
Admin → Site Settings
→ Enable Application Plugins
→ Applications
→ Enable the desired application
→ Reload App Config
→ if NEEDS_MIGRATION: Upgrade Database Schema
→ Reload App Config
→ if NEEDS_CONFIGURATION: complete plugin-owned configuration
→ Reload App Config
→ ACTIVE
Flask-AAS does not currently clone, upload, update, or otherwise acquire plugin source; repository placement is an operator/deployment action. Reload App Config performs the structural Gunicorn reload and reconciles the worker runtime snapshot with persisted state. For an already structurally loaded plugin, request/navigation gating reads the current persisted enabled/configured flags immediately; reload is still the normal administrative step for reconciling runtime status and any startup-time plugin behavior. Database migration remains a separate privileged operation. The explicit plugin CLI is also available for diagnostics, host-provided migration management, configuration, and plugin-owned maintenance when deliberately invoked:
python manage.py plugin run example status
python manage.py plugin run your_plugin db current
python manage.py plugin run your_plugin db upgrade
python manage.py plugin run example configure
python manage.py plugin run example add-item "example value"The CLI dispatcher does not make the plugin globally active by itself; web runtime activation still follows persisted enablement and the worker reload boundary.
If plugin source was renamed, moved, removed, or its manifest/import path changed and the database registry no longer matches the filesystem, see docs/plugin-troubleshooting.md before deleting schema or business data. PluginRegistration is persisted state; renaming a directory alone does not rewrite an existing registration row.
- Seed scripts run once on clean DB
default_role_idin.envcontrols default user role- Admin panel for user/role/settings/application management
User Storage Pathcontrols the complete local profile-image storage directory; relative paths resolve from the Flask application root and absolute paths are honored as configured.- Keep deployment SMTP credentials in external configuration, or enable the encrypted Site Settings override deliberately.
- Treat enabled Python application plugins as trusted native application code and keep the Flask-AAS process/container least-privileged.
Keep log tables lean with the CLI cleanup command:
python manage.py cleanup-logins --days 7--days→ Number of days to retain logs (default: 7)- Deletes
AuditLoginrecords older than the retention period
-
Run
cleanup-loginsregularly when login-attempt retention is required -
Monitor audit logs for anomalies
-
Enable email verification & CAPTCHA for public reg
-
Backup DB & user assets