From d6c57b91a08f50a017fec7f0f7a5b4f2d17cf9cf Mon Sep 17 00:00:00 2001 From: Riccardo Corsi Date: Thu, 4 Dec 2025 17:29:56 +0100 Subject: [PATCH 1/7] =?UTF-8?q?Auth:=20Phase=201=20=E2=80=94=20add=20User?= =?UTF-8?q?=20model,=20login/register/change-password,=20tests=20and=20fix?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 16 +- app.py | 6 + fix-proposal-image-field.patch | 7 + fix-smoke-auth-init.patch | 16 ++ migrate_db/migrate.py | 26 ++- .../migrations/V8_0__create_users_table.sql | 13 ++ model/entity/user.py | 31 ++++ model/repository/user_repository.py | 36 ++++ pytest-output.txt | 167 ++++++++++++++++++ requirements.txt | 3 +- route/auth_route.py | 95 ++++++++++ route/proposal_route.py | 3 +- scripts/smoke_auth_check.py | 21 +++ service/auth_service.py | 62 +++++++ specs/feature/feat-auth-layer.md | 64 +++---- templates/index.html | 19 ++ templates/user/change_password.html | 45 +++++ templates/user/login.html | 59 +++++++ templates/user/register.html | 49 +++++ tests/integration/test_auth_flow.py | 46 +++++ tests/integration/test_smoke_auth_pages.py | 34 ++++ tests/unit/test_auth_service.py | 12 ++ tests/unit/test_proposal_update.py | 9 +- 23 files changed, 797 insertions(+), 42 deletions(-) create mode 100644 fix-proposal-image-field.patch create mode 100644 fix-smoke-auth-init.patch create mode 100644 migrate_db/migrations/V8_0__create_users_table.sql create mode 100644 model/entity/user.py create mode 100644 model/repository/user_repository.py create mode 100644 pytest-output.txt create mode 100644 route/auth_route.py create mode 100644 scripts/smoke_auth_check.py create mode 100644 service/auth_service.py create mode 100644 templates/user/change_password.html create mode 100644 templates/user/login.html create mode 100644 templates/user/register.html create mode 100644 tests/integration/test_auth_flow.py create mode 100644 tests/integration/test_smoke_auth_pages.py create mode 100644 tests/unit/test_auth_service.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0e7eef5..492bf11 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -346,7 +346,15 @@ python migrate.py migrate --- -**Last Updated**: November 25, 2025 -**Python Version**: 3.12+ -**Test Coverage**: 44 tests passing (unit + integration) -**Status**: Active Development - Spec 002 Complete +**Last Updated**: December 4, 2025 +**Python Version**: 3.14+ +**Test Coverage**: multiple unit & integration tests (see `tests/`) β€” smoke auth pages test added +**Status**: Active Development - Auth + Migrations integrated + +### Recent Updates (Dec 4, 2025) +- Added authentication layer: `User` model, `UserRepository`, `AuthService` (passlib + pbkdf2_sha256 default, bcrypt available), and `auth` blueprint with login/register/change-password/logout flows. +- Authentication uses **email** as the canonical identity key (username field removed). +- Templates updated: `index.html` now exposes a `{% block content %}` to allow auth and other pages to render inside the main layout; login/register templates stored under `templates/user/`. +- Migrations: runner `migrate_db/migrate.py` now prefers the `pyway` CLI (`pyway migrate`) for applying migrations and will fall back to direct SQL application if pyway is unavailable. The project `pyway.yaml` configures the `pyway` metadata table in the SQLite DB. +- Tests: integration smoke test `tests/integration/test_smoke_auth_pages.py` added to verify auth pages render within site layout. + diff --git a/app.py b/app.py index 44215bc..8d377ed 100644 --- a/app.py +++ b/app.py @@ -30,6 +30,8 @@ from route.proposal_route import proposal_bp from route.radio_source_route import radio_source_bp from route.listen_route import listen_bp +from route.auth_route import auth_bp +from service.auth_service import AuthService app.register_blueprint(blueprint=main_bp) app.register_blueprint(blueprint=database_bp) @@ -37,6 +39,10 @@ app.register_blueprint(blueprint=proposal_bp) app.register_blueprint(blueprint=radio_source_bp) app.register_blueprint(blueprint=listen_bp) +app.register_blueprint(blueprint=auth_bp) + +# Initialize authentication (LoginManager) +AuthService(app) # Db is created only by pyway migrations diff --git a/fix-proposal-image-field.patch b/fix-proposal-image-field.patch new file mode 100644 index 0000000..120c9e8 --- /dev/null +++ b/fix-proposal-image-field.patch @@ -0,0 +1,7 @@ +diff --git a/route/proposal_route.py b/route/proposal_route.py +--- a/route/proposal_route.py ++++ b/route/proposal_route.py +@@ +- image = request.form.get('image') ++ # Accept either 'image' (form) or 'image_url' (tests/clients) ++ image = request.form.get('image') or request.form.get('image_url') \ No newline at end of file diff --git a/fix-smoke-auth-init.patch b/fix-smoke-auth-init.patch new file mode 100644 index 0000000..3b9a9a4 --- /dev/null +++ b/fix-smoke-auth-init.patch @@ -0,0 +1,16 @@ +diff --git a/tests/integration/test_smoke_auth_pages.py b/tests/integration/test_smoke_auth_pages.py +--- a/tests/integration/test_smoke_auth_pages.py ++++ b/tests/integration/test_smoke_auth_pages.py +@@ +- # Register blueprints only if they are not already registered (idempotent) +- for bp in (main_bp, database_bp, analysis_bp, proposal_bp, radio_source_bp, listen_bp, auth_bp): +- if bp.name not in app.blueprints: +- app.register_blueprint(bp) +- AuthService(app) ++ # Register blueprints only if they are not already registered (idempotent) ++ for bp in (main_bp, database_bp, analysis_bp, proposal_bp, radio_source_bp, listen_bp, auth_bp): ++ if bp.name not in app.blueprints: ++ app.register_blueprint(bp) ++ # Initialize AuthService only if a login manager isn't already present on the app ++ if not hasattr(app, 'login_manager'): ++ AuthService(app) \ No newline at end of file diff --git a/migrate_db/migrate.py b/migrate_db/migrate.py index c58b177..6cd826c 100644 --- a/migrate_db/migrate.py +++ b/migrate_db/migrate.py @@ -25,14 +25,32 @@ def run_command(cmd: list) -> bool: def run_migrations(): - """Run all pending database migrations using sqlite3.""" - print("πŸš€ Starting database migrations with sqlite3...") + """Run database migrations using pyway if available, otherwise fallback to sqlite3 SQL application. + + This will prefer the pyway CLI so pyway's migration tracking table (configured in `pyway.yaml`) + is used. If the pyway binary cannot be executed, we fall back to applying the SQL files + directly (the old behavior). + """ + print("πŸš€ Starting database migrations (pyway preferred)...") # Ensure instance directory exists instance_dir = Path("../instance") instance_dir.mkdir(exist_ok=True) - # Run migrations + # Try to run pyway CLI + # pyway CLI expects the migration command 'migrate' (not 'apply') + pyway_cmd = [PYWAY_PATH.as_posix(), "--config", "pyway.yaml", "migrate"] + try: + result = run_command(pyway_cmd) + if result: + print("βœ… Migrations applied via pyway.") + return + else: + print("⚠️ pyway run failed, falling back to direct SQL application.") + except Exception: + print("⚠️ pyway CLI not available or failed to run; falling back to direct SQL application.") + + # Fallback: apply SQL files directly import sqlite3 db_path = Path("../instance/radio_sources.db") conn = sqlite3.connect(str(db_path)) @@ -51,7 +69,7 @@ def run_migrations(): conn.commit() conn.close() - print("βœ… All migrations completed successfully!") + print("βœ… All migrations completed successfully (direct SQL).") def show_migration_status(): diff --git a/migrate_db/migrations/V8_0__create_users_table.sql b/migrate_db/migrations/V8_0__create_users_table.sql new file mode 100644 index 0000000..05265d2 --- /dev/null +++ b/migrate_db/migrations/V8_0__create_users_table.sql @@ -0,0 +1,13 @@ +-- Migration: create users table +DROP TABLE IF EXISTS stream_analysis; +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email VARCHAR(255) NOT NULL UNIQUE, + hash_password VARCHAR(512) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user', + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME, + last_modified_at DATETIME +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); \ No newline at end of file diff --git a/model/entity/user.py b/model/entity/user.py new file mode 100644 index 0000000..a781797 --- /dev/null +++ b/model/entity/user.py @@ -0,0 +1,31 @@ +from typing import Literal +from sqlalchemy.sql import func +from database import db + + +class User(db.Model): + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + hash_password = db.Column(db.String(512), nullable=False) + role = db.Column(db.String(20), nullable=False, default='user') + is_active = db.Column(db.Boolean, nullable=False, default=True) + + # Timestamps: keep `created_at` and `last_modified_at` per project preference + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) + last_modified_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + def get_id(self): + return str(self.id) + + @property + def is_authenticated(self) -> Literal[True]: + return True + + @property + def is_anonymous(self) -> Literal[False]: + return False + + def __repr__(self): + return f"" diff --git a/model/repository/user_repository.py b/model/repository/user_repository.py new file mode 100644 index 0000000..4279ad3 --- /dev/null +++ b/model/repository/user_repository.py @@ -0,0 +1,36 @@ +from typing import Optional +from database import db +from model.entity.user import User + + +class UserRepository: + def __init__(self, session=None): + self.session = session or db.session + + def find_by_id(self, user_id: int) -> Optional[User]: + return self.session.query(User).filter(User.id == user_id).first() + + def find_by_email(self, email: str) -> Optional[User]: + return self.session.query(User).filter(User.email == email).first() + + def create(self, email: str, hash_password: str, role: str = 'user') -> User: + user = User(email=email, hash_password=hash_password, role=role) + + self.session.add(user) + self.session.commit() + self.session.refresh(user) + return user + + def update_password(self, user: User, new_hash: str) -> User: + user.hash_password = new_hash + + self.session.commit() + self.session.refresh(user) + return user + + def set_role(self, user: User, role: str) -> User: + user.role = role + + self.session.commit() + self.session.refresh(user) + return user diff --git a/pytest-output.txt b/pytest-output.txt new file mode 100644 index 0000000..71cd260 --- /dev/null +++ b/pytest-output.txt @@ -0,0 +1,167 @@ +============================= test session starts ============================== +platform linux -- Python 3.14.0, pytest-8.0.0, pluggy-1.6.0 -- /home/riccardo/Documenti/Programming/Projects/RadioChWeb/.venv/bin/python +cachedir: .pytest_cache +rootdir: /home/riccardo/Documenti/Programming/Projects/RadioChWeb +plugins: cov-4.1.0 +collecting ... collected 53 items + +tests/integration/test_auth_flow.py::test_register_login_logout_flow PASSED [ 1%] +tests/integration/test_smoke_auth_pages.py::test_smoke_auth_pages_render FAILED [ 3%] +tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_complete_save_workflow PASSED [ 5%] +tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_duplicate_stream_url_prevention PASSED [ 7%] +tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_proposal_rejection_workflow PASSED [ 9%] +tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_proposal_update_workflow PASSED [ 11%] +tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_validation_with_missing_required_fields PASSED [ 13%] +tests/integration/test_validate_and_add_workflow.py::TestValidateAndAddWorkflow::test_insecure_stream_warning PASSED [ 15%] +tests/unit/test_analysis_routes.py::test_delete_analysis_route_removes_row PASSED [ 16%] +tests/unit/test_analysis_routes.py::test_approve_analysis_route_creates_proposal PASSED [ 18%] +tests/unit/test_auth_service.py::test_hash_and_verify_roundtrip PASSED [ 20%] +tests/unit/test_proposal_update.py::test_update_proposal_post FAILED [ 22%] +tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_success PASSED [ 24%] +tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_missing_required_fields PASSED [ 26%] +tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_invalid_url_format PASSED [ 28%] +tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_duplicate_stream_url PASSED [ 30%] +tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_insecure_stream_warning PASSED [ 32%] +tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_proposal_nonexistent_proposal PASSED [ 33%] +tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_url_format_valid_urls PASSED [ 35%] +tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_validate_url_format_invalid_urls PASSED [ 37%] +tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_check_duplicate_stream_url_no_duplicate PASSED [ 39%] +tests/unit/test_proposal_validation_service.py::TestProposalValidationService::test_check_duplicate_stream_url_duplicate_exists PASSED [ 41%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_save_from_proposal_success PASSED [ 43%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_save_from_proposal_validation_failure PASSED [ 45%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_save_from_proposal_not_found PASSED [ 47%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_reject_proposal_success PASSED [ 49%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_reject_proposal_not_found PASSED [ 50%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_update_proposal_success PASSED [ 52%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_update_proposal_not_found PASSED [ 54%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_get_proposal PASSED [ 56%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_get_all_proposals PASSED [ 58%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_reject_proposal PASSED [ 60%] +tests/unit/test_radio_source_service.py::TestRadioSourceService::test_get_all_radio_sources PASSED [ 62%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_unsupported_protocol_rejection PASSED [ 64%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_https_security_detection PASSED [ 66%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_http_security_warning PASSED [ 67%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_ffmpeg_authoritative_over_curl PASSED [ 69%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_curl_header_extraction PASSED [ 71%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_ffmpeg_output_parsing PASSED [ 73%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_metadata_detection_icecast PASSED [ 75%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_metadata_detection_shoutcast PASSED [ 77%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_prerequisites_check_missing_ffmpeg PASSED [ 79%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_timeout_handling PASSED [ 81%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_extract_metadata_from_ffmpeg_output_basic PASSED [ 83%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_analyze_stream_populates_extracted_metadata_from_ffmpeg PASSED [ 84%] +tests/unit/test_stream_analysis_service.py::TestStreamAnalysisService::test_save_analysis_as_proposal_basic PASSED [ 86%] +tests/unit/test_stream_type_service.py::TestStreamTypeService::test_find_stream_type_id PASSED [ 88%] +tests/unit/test_stream_type_service.py::TestStreamTypeService::test_find_stream_type_id_not_found PASSED [ 90%] +tests/unit/test_stream_type_service.py::TestStreamTypeService::test_get_stream_type PASSED [ 92%] +tests/unit/test_stream_type_service.py::TestStreamTypeService::test_get_stream_type_not_found PASSED [ 94%] +tests/unit/test_stream_type_service.py::TestStreamTypeService::test_get_all_stream_types PASSED [ 96%] +tests/unit/test_stream_type_service.py::TestStreamTypeService::test_get_predefined_types_map PASSED [ 98%] +tests/unit/test_stream_type_service.py::TestStreamTypeService::test_initialize_predefined_types PASSED [100%] + +=================================== FAILURES =================================== +_________________________ test_smoke_auth_pages_render _________________________ + +test_app = + + def test_smoke_auth_pages_render(test_app): +> register_blueprints(test_app) + +tests/integration/test_smoke_auth_pages.py:23: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +tests/integration/test_smoke_auth_pages.py:20: in register_blueprints + AuthService(app) +service/auth_service.py:17: in __init__ + self.init_app(app) +service/auth_service.py:22: in init_app + lm.init_app(app) +.venv/lib64/python3.14/site-packages/flask_login/login_manager.py:137: in init_app + app.after_request(self._update_remember_cookie) +.venv/lib64/python3.14/site-packages/flask/sansio/scaffold.py:43: in wrapper_func + self._check_setup_finished(f_name) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = , f_name = 'after_request' + + def _check_setup_finished(self, f_name: str) -> None: + if self._got_first_request: +> raise AssertionError( + f"The setup method '{f_name}' can no longer be called" + " on the application. It has already handled its first" + " request, any changes will not be applied" + " consistently.\n" + "Make sure all imports, decorators, functions, etc." + " needed to set up the application are done before" + " running it." + ) +E AssertionError: The setup method 'after_request' can no longer be called on the application. It has already handled its first request, any changes will not be applied consistently. +E Make sure all imports, decorators, functions, etc. needed to set up the application are done before running it. + +.venv/lib64/python3.14/site-packages/flask/sansio/app.py:417: AssertionError +__________________________ test_update_proposal_post ___________________________ + +test_app = +test_db = + + def test_update_proposal_post(test_app, test_db): + # Create a proposal in the test DB + proposal = Proposal( + stream_url='https://stream.example.com/test', + name='Old Name', + website_url='https://old.example.com', + stream_type_id=1, + is_secure=False, + country='OldCountry', + description='Old description', + image_url='https://old.example.com/img.png' + ) + test_db.add(proposal) + test_db.commit() + test_db.refresh(proposal) + + # Prepare updated data + data = { + 'name': 'New Name', + 'website_url': 'https://new.example.com', + 'country': 'Italy', + 'description': 'New description', + 'image_url': 'https://new.example.com/img.png' + } + + # Register blueprint so url_for('proposal.index') resolves during the view + from route.proposal_route import proposal_bp + + # Register blueprint so url_for('proposal.index') resolves during the view + from route.proposal_route import proposal_bp + # register only if not present to avoid "register_blueprint after first request" errors + if proposal_bp.name not in test_app.blueprints: + test_app.register_blueprint(proposal_bp) + + # Call the view function within a request context + with test_app.test_request_context(f'/proposal/{proposal.id}', method='POST', data=data): + from route.proposal_route import proposal_detail + resp = proposal_detail(proposal.id) + + # Expect a redirect response to proposals index + assert resp.status_code == 302 + + # Reload from DB and assert changes + updated = test_db.query(Proposal).filter(Proposal.id == proposal.id).first() + assert updated is not None + assert updated.name == 'New Name' + assert updated.website_url == 'https://new.example.com' + assert updated.country == 'Italy' + assert updated.description == 'New description' +> assert updated.image_url == 'https://new.example.com/img.png' +E AssertionError: assert 'https://old.example.com/img.png' == 'https://new.example.com/img.png' +E +E - https://new.example.com/img.png +E ? ^^^ +E + https://old.example.com/img.png +E ? ^^^ + +tests/unit/test_proposal_update.py:53: AssertionError +=========================== short test summary info ============================ +FAILED tests/integration/test_smoke_auth_pages.py::test_smoke_auth_pages_render +FAILED tests/unit/test_proposal_update.py::test_update_proposal_post - Assert... +========================= 2 failed, 51 passed in 0.63s ========================= diff --git a/requirements.txt b/requirements.txt index b12f300..ddac433 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ pytest==8.0.0 pytest-cov==4.1.0 pydantic==2.12.0 pyway==0.3.32 -Flask_WTF==1.2.1 \ No newline at end of file +Flask_WTF==1.2.1 +passlib[bcrypt]==1.7.4 \ No newline at end of file diff --git a/route/auth_route.py b/route/auth_route.py new file mode 100644 index 0000000..45bf17b --- /dev/null +++ b/route/auth_route.py @@ -0,0 +1,95 @@ +from flask import Blueprint, render_template, request, redirect, session, url_for, flash +from functools import wraps + +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import DataRequired, Email, EqualTo, Length +from flask_login import login_user, logout_user, login_required, current_user +from model.entity.user import User +from service.auth_service import AuthService +from model.repository.user_repository import UserRepository + +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') + +auth_service = AuthService() + + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + submit = SubmitField('Login') + + +class RegisterForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8)]) + confirm = PasswordField('Confirm', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Register') + + +class ChangePasswordForm(FlaskForm): + old_password = PasswordField('Current password', validators=[DataRequired()]) + new_password = PasswordField('New password', validators=[DataRequired(), Length(min=8)]) + confirm = PasswordField('Confirm', validators=[DataRequired(), EqualTo('new_password')]) + submit = SubmitField('Change Password') + +@auth_bp.route('/change_password', methods=['GET', 'POST']) +@login_required +def change_password(): + form = ChangePasswordForm() + if form.validate_on_submit(): + old = form.old_password.data + new = form.new_password.data + user = current_user + res = auth_service.verify_password(old, user.hash_password) + if isinstance(res, tuple): + verified = bool(res[0]) + else: + verified = bool(res) + if not verified: + flash('Current password incorrect', 'error') + return render_template('user/change_password.html', form=form), 400 + auth_service.change_password(user, new) + flash('Password changed successfully', 'success') + return redirect(url_for('main.index')) + return render_template('user/change_password.html', form=form) + +user_repo = UserRepository() + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + form = LoginForm() + if form.validate_on_submit(): + user: User = user_repo.find_by_email(form.email.data) # adapt to your repo/service call + + if user and auth_service.verify_password(form.password.data, user.hash_password): + login_user(user) + flash('Signed in successfully', 'success') + next_page = request.args.get('next') or url_for('main.index') + return redirect(next_page) + # on failure: flash AND render the login page so message is visible there + flash('Invalid email or password', 'error') + return render_template('user/login.html', form=form) + + return render_template('user/login.html', form=form) + + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('main.index')) + + +@auth_bp.route('/register', methods=['GET', 'POST']) +def register(): + form = RegisterForm() + if form.validate_on_submit(): + try: + auth_service.register_user(email=form.email.data, password=form.password.data) + flash('Registration successful. Please login.', 'success') + return redirect(url_for('auth.login')) + except ValueError as e: + flash(str(e), 'error') + return render_template('user/register.html', form=form) + diff --git a/route/proposal_route.py b/route/proposal_route.py index e8baec3..590b7ff 100644 --- a/route/proposal_route.py +++ b/route/proposal_route.py @@ -107,7 +107,8 @@ def proposal_detail(proposal_id): website_url = request.form.get('website_url') country = request.form.get('country') description = request.form.get('description') - image = request.form.get('image') + # Accept either 'image' (form) or 'image_url' (tests/clients) + image = request.form.get('image') or request.form.get('image_url') update_dto = ProposalUpdateRequest( name=name, diff --git a/scripts/smoke_auth_check.py b/scripts/smoke_auth_check.py new file mode 100644 index 0000000..84c292e --- /dev/null +++ b/scripts/smoke_auth_check.py @@ -0,0 +1,21 @@ +# Smoke test for auth pages using Flask test client +import sys +from pathlib import Path + +# Ensure project root is on path so `import app` works when running the script +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app import app + +def fetch(path): + with app.test_client() as c: + r = c.get(path) + print(f"GET {path} -> {r.status_code}, {len(r.data)} bytes") + snippet = r.data.decode('utf-8', errors='replace')[:400] + print('--- snippet ---') + print(snippet) + print('--- end ---\n') + +if __name__ == '__main__': + fetch('/auth/login') + fetch('/auth/register') diff --git a/service/auth_service.py b/service/auth_service.py new file mode 100644 index 0000000..e8da1e7 --- /dev/null +++ b/service/auth_service.py @@ -0,0 +1,62 @@ +from passlib.context import CryptContext +from flask_login import LoginManager + +from model.repository.user_repository import UserRepository +from model.entity.user import User + +# Prefer a pure-Python secure scheme for portability in dev/test; keep bcrypt available +# Use pbkdf2_sha256 as default to avoid system bcrypt C-extension issues in some environments +pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt"], default="pbkdf2_sha256", deprecated="auto") + + +class AuthService: + def __init__(self, app=None): + self.user_repo = UserRepository() + self.login_manager = None + if app is not None: + self.init_app(app) + + def init_app(self, app): + lm = LoginManager() + lm.login_view = 'auth.login' + lm.init_app(app) + + @lm.user_loader + def load_user(user_id): + return self.user_repo.find_by_id(int(user_id)) + + self.login_manager = lm + + def hash_password(self, password: str) -> str: + return pwd_context.hash(password) + + def verify_password(self, plain: str, hashed: str) -> (bool, str | None): + """ + Verify password; return (verified, new_hash_or_none) + new_hash_or_none is non-None when the hash should be updated (lazy upgrade) + """ + verified: bool = pwd_context.verify(plain, hashed) + + new_hash: str | None = None + try: + # verify_and_update returns (bool, new_hash) + verified, new_hash = pwd_context.verify_and_update(plain, hashed) + except Exception: + # fallback to simple verify if verify_and_update unavailable + verified = pwd_context.verify(plain, hashed) + new_hash = None + + return verified, new_hash + + def register_user(self, email: str, password: str, role: str = 'user') -> User: + existing: User | None = self.user_repo.find_by_email(email) + + if existing: + raise ValueError('Email already registered') + + hashed: str = self.hash_password(password) + return self.user_repo.create(email=email, hash_password=hashed, role=role) + + def change_password(self, user: User, new_password: str) -> User: + new_hash: str = self.hash_password(new_password) + return self.user_repo.update_password(user, new_hash) diff --git a/specs/feature/feat-auth-layer.md b/specs/feature/feat-auth-layer.md index b622710..78692c8 100644 --- a/specs/feature/feat-auth-layer.md +++ b/specs/feature/feat-auth-layer.md @@ -1,4 +1,4 @@ -# Feature: Authentication Layer# Feature: Authentication Layer +# Feature: Authentication Layer ## Checklist (High Level) @@ -8,14 +8,14 @@ - Session-based login using Flask-login -- Add roles/permissions: user (generic), admin(manage resources) +- Add roles/permissions: user (generic), admin (manage resources) -- Decide policy: -* listen and browsing without permissions -* role user (authenticated) may analyze stream, propose stream, save as radio source. The user_name is associated to the radio source saved -* only admin may resources -* Edit of a proposal is allowed to the user who created it or to admin -* Deletion of analysis is only allowed to admin +- Policy: + * listen and browsing without permissions + * role user (authenticated) may analyze stream, propose stream, save as radio source. The user_name is associated to the radio source saved + * only admin may resources + * Edit of a proposal is allowed to the user who created it or to admin + * Deletion of analysis is only allowed to admin - Protect sensitive routes - Require login functions as needed @@ -29,7 +29,7 @@ - Revisit listen-logging plan: Include user_id for next feat implementation # User model - +```python @data class id: int user_name: str @@ -37,30 +37,34 @@ hash_password: str last_modiied_at: datetime role: str # 'user' or 'admin' - +``` ## PLAN -Plan: Implement User Authentication -TL;DR β€” Add a proper user model, repository, and an auth service using Passlib + Flask-Login; create protected routes and three templates (login.html, register.html, change_password.html) with CSRF; update app registration and templates to respect roles. This gives session-based auth, role checks, and a migration-ready DB model with tests for register/login/logout and access control. - -Steps -Add DB model & migration: create model/entity/user.py and a migration migrate_db/migrations/V8_0__create_users_table.sql with columns id, email (unique), username (unique), password_hash, role (user/admin), is_active, created_at, updated_at. -Add UserRepository: add model/repository/user_repository.py with create, find_by_id, find_by_email, find_by_username, update_password, set_role, exists helpers. -Add AuthService + password policy: add service/auth_service.py using passlib (Argon2id preferred, bcrypt fallback) and helpers for register_user, verify_password (with lazy rehash on login), and expose a simple interface for routes. Initialize Flask-Login user loader in this service or a small auth initializer. -New routes & templates: add route/user_route.py (blueprint auth_bp) with GET/POST /login, GET/POST /register, GET/POST /change_password, POST /logout. Create templates/login.html, templates/register.html, templates/change_password.html reusing the app's Bootstrap layout and including CSRF tokens (Flask-WTF or csrf_token() calls). -Protect routes & UI: apply @login_required (Flask-Login) and @role_required('admin') where needed (e.g., analysis delete). Update templates to show/hide actions based on current_user.is_authenticated and current_user.role. Add a small helper route/_auth_helpers.py if you prefer decorator-based role checks. -Tests & fixtures: add tests in tests/unit/test_auth.py and integration tests for route protection. Add fixtures for a test user and admin, client helpers to log in. Ensure CSRF disabled in test config or include tokens. -App wiring & docs: update app.py to register the auth_bp, initialize Flask-Login and CSRFProtect, add required packages to requirements.txt, and add a short doc/spec in specs/feat-auth-layer.md describing env vars and migration steps. -Further Considerations -Password hashing options: use Argon2id via passlib[argon2] (recommended). If Argon2 not available, use passlib[bcrypt]. Use pwd_context.verify_and_update() for lazy migration. Ensure DB field length (>= 255). -Session management: use Flask-Login (recommended). It integrates cleanly with Flask and simplifies current_user logic. Provide an adapter so User entity implements required attributes/methods or a lightweight wrapper. -Security & UX: enable CSRF on all forms, rate-limit login endpoint (simple rate limiter or use Flask-Limiter later), require email confirmation and/or password reset as next steps. For proposal edit permissions: allow only the author or admin to edit. For delete of analysis: admin-only, as in spec. -Migration & rollout: add SQL migration; deploy to staging; test registration/login flows and verify that lazy rehashing updates stored hashes. Set SECRET_KEY and any passlib salts in environment. -Tests: include unit tests for AuthService password flows and integration tests for protected routes. Mock expensive Argon2 parameters in tests to keep runtime fast. -Please review this plan and confirm or request changes (e.g., prefer bcrypt vs Argon2, use custom decorators vs Flask-Login only). Once you confirm, I will: (A) create the tracked todo items (done), and (B) implement the work in a feature branch with migrations, templates, routes, and tests. Which option do you want next β€” I can start coding the feature branch now. - -## FOLLOW EXAMPLES ONLY READ UNTILΓ§ HERE FOR FEAT NEEDS +### Plan: Implement User Authentication +TL;DR β€” Add a proper user model, repository, and an auth service using Passlib + Flask-Login; create protected routes and three templates (login.html, register.html, change_password.html) with CSRF; update app registration and templates to respect roles. + +This gives session-based auth, role checks, and a migration-ready DB model with tests for register/login/logout and access control. + +### Steps +1. Add DB model & migration: create model/entity/user.py and a migration migrate_db/migrations/V8_0__create_users_table.sql with columns id, email (unique), username (unique), password_hash, role (user/admin), is_active, created_at, updated_at. +1. Add UserRepository: add model/repository/user_repository.py with create, find_by_id, find_by_email, find_by_username, update_password, set_role, exists helpers. +1. Add AuthService + password policy: add service/auth_service.py using passlib (Argon2id preferred, bcrypt fallback) and helpers for register_user, verify_password (with lazy rehash on login), and expose a simple interface for routes. Initialize Flask-Login user loader in this service or a small auth initializer. +1. New routes & templates: add route/user_route.py (blueprint auth_bp) with GET/POST /login, GET/POST /register, GET/POST /change_password, POST /logout. Create templates/login.html, templates/register.html, templates/change_password.html reusing the app's Bootstrap layout and including CSRF tokens (Flask-WTF or csrf_token() calls). +1. Protect routes & UI: apply @login_required (Flask-Login) and @role_required('admin') where needed (e.g., analysis delete). Update templates to show/hide actions based on current_user.is_authenticated and current_user.role. Add a small helper route/_auth_helpers.py if you prefer decorator-based role checks. +1. Tests & fixtures: add tests in tests/unit/test_auth.py and integration tests for route protection. Add fixtures for a test user and admin, client helpers to log in. Ensure CSRF disabled in test config or include tokens. +1. App wiring & docs: update app.py to register the auth_bp, initialize Flask-Login and CSRFProtect, add required packages to requirements.txt, and add a short doc/spec in specs/feat-auth-layer.md describing env vars and migration steps. + +### Further Considerations +- Password hashing options: use Argon2id via passlib[argon2] (recommended). If Argon2 not available, use passlib[bcrypt]. Use pwd_context.verify_and_update() for lazy migration. Ensure DB field length (>= 255). +- Session management: use Flask-Login (recommended). It integrates cleanly with Flask and simplifies current_user logic. Provide an adapter so User entity implements required attributes/methods or a lightweight wrapper. +- Security & UX: enable CSRF on all forms, rate-limit login endpoint (simple rate limiter or use Flask-Limiter later), require email confirmation and/or password reset as next steps. For proposal edit permissions: allow only the author or admin to edit. - For delete of analysis: admin-only, as in spec. + +- Migration & rollout: add SQL migration; deploy to staging; test registration/login flows and verify that lazy rehashing updates stored hashes. Set SECRET_KEY and any passlib salts in environment. + +- Tests: include unit tests for AuthService password flows and integration tests for protected routes. Mock expensive Argon2 parameters in tests to keep runtime fast. + +## FOLLOW EXAMPLES ## Quick Route-Protection Decorator (Example) diff --git a/templates/index.html b/templates/index.html index 998f301..7da9f87 100644 --- a/templates/index.html +++ b/templates/index.html @@ -16,12 +16,30 @@ Analyze Stream View Proposals Database + {% if current_user.is_authenticated %} + Signed in as {{ current_user.email }} + Change password + Logout + {% else %} + Login + Register + {% endif %}
+ {% block content %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %}

Welcome to RadioChWeb

Discover and enjoy radio streams.

@@ -43,6 +61,7 @@
{{ source.name }}
{% endfor %} + {% endblock %}