From 36fe2d39be3df24c550b00864472c2e3077ef81b Mon Sep 17 00:00:00 2001 From: Karla Date: Tue, 19 May 2026 19:34:25 +0200 Subject: [PATCH 1/3] Added exchange token for oauth users --- app/routes/auth.py | 34 +++-- app/services/google_oauth_service.py | 10 ++ app/services/password_service.py | 27 +++- app/static/makeascene.openapi.yaml | 79 +++++++++-- tests/unit/test_google_oauth_service.py | 167 +++++++++++++++++++++--- tests/unit/test_password_service.py | 55 +++++++- 6 files changed, 331 insertions(+), 41 deletions(-) diff --git a/app/routes/auth.py b/app/routes/auth.py index 789e389..9247362 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -62,21 +62,37 @@ def google_callback(): if not user_info: return {"error": "Failed to fetch user info from Google"}, 400 - res = current_app.google_oauth_service.authenticate_user(user_info) - if not res: + exchange = current_app.google_oauth_service.authenticate_user(user_info) + if not exchange: return {"error": "Failed to authenticate user"}, 400 - token, refresh_token = res return { - "message": "Login successful. Use token for authentication.", + "exchange": exchange + }, 201 + + +@auth.route("/oauth/exchange", methods=["POST"]) +@validate +def exchange_token(): + data = request.get_json() + exchange = data.get("exchange") + res = current_app.google_oauth_service.exchange(exchange) + if not res: + return { + "error": "unauthorized", + "message": "Invalid exchange token." + }, 401 + token, refresh_token = res + return { + "message": "Login successful.", "access_token": token, "refresh_token": refresh_token - }, 201 + }, 200 @auth.route("/email/confirm", methods=["GET"]) @validate -def confirm_password(): +def confirm_email(): params = request.args token = params.get("token") if current_app.auth_service.confirm_email(token): @@ -84,10 +100,10 @@ def confirm_password(): return {"error": "unauthorized", "message": "Invalid credentials."}, 401 -@auth.route("/email/resend", methods=["GET"]) +@auth.route("/email/resend", methods=["POST"]) @validate def resend_mail(): - params = request.args - email = params.get("email") + data = request.get_json() + email = (data.get("email") or "").strip().replace(" ", "") current_app.user_service.resend_confirmation_email(email) return {"message": "Email sent if an unconfirmed user with that email exists."}, 200 diff --git a/app/services/google_oauth_service.py b/app/services/google_oauth_service.py index aa216b0..397bdb5 100644 --- a/app/services/google_oauth_service.py +++ b/app/services/google_oauth_service.py @@ -21,6 +21,16 @@ def authenticate_user(self, token: dict) -> tuple[str, str] | None: return None self.user_repo.update_user(user) + return PasswordService.generate_exchange_token(user.email) + + def exchange(self, exchange_token: str): + email = PasswordService.verify_exchange_token(exchange_token) + if not email: + return None + user = self.user_repo.get_user_by_email(email) + if not user: + return None + jwt = PasswordService.generate_access_token(user.id) refresh_token = PasswordService.generate_refresh_token() if refresh_token is None: diff --git a/app/services/password_service.py b/app/services/password_service.py index a628ae8..05fa216 100644 --- a/app/services/password_service.py +++ b/app/services/password_service.py @@ -1,3 +1,5 @@ +from cmath import exp + from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError import jwt @@ -46,7 +48,6 @@ def hash_refresh_token(token: str) -> str: def hash_confirm_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() - @staticmethod def generate_access_token(user_id: int): payload = { @@ -71,3 +72,27 @@ def verify_token(token: str): return None except jwt.InvalidTokenError: return None + + @staticmethod + def generate_exchange_token(email: str): + payload = { + "email": email, + "type": "exchange", + "exp": ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=1) + ).timestamp() + } + return jwt.encode(payload, jwt_secret_key, algorithm="HS256") + + @staticmethod + def verify_exchange_token(token: str): + try: + payload = jwt.decode(token, jwt_secret_key, algorithms=["HS256"]) + if payload.get("type") != "exchange": + return None + return payload.get("email") + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None diff --git a/app/static/makeascene.openapi.yaml b/app/static/makeascene.openapi.yaml index 7571e81..b5465ad 100644 --- a/app/static/makeascene.openapi.yaml +++ b/app/static/makeascene.openapi.yaml @@ -108,21 +108,21 @@ paths: description: '' security: [] /auth/email/resend: - get: + post: summary: Resend Email deprecated: false description: Used to request a resend of the confirmation email operationId: resendEmail tags: - Authentication - parameters: - - name: email - in: query - description: '' - required: true - example: test@test.com - schema: - type: string + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EmailResendRequest' + examples: {} + required: true responses: '200': description: '' @@ -145,6 +145,7 @@ paths: summary: Change Password deprecated: false description: Used when the password should be changed or was forgotten + operationId: changePassword tags: - Authentication parameters: [] @@ -251,6 +252,39 @@ paths: $ref: '#/components/responses/InternalServerError' description: '' security: [] + /auth/oauth/exchange: + post: + summary: Exchange login info for oauth users + deprecated: false + description: 'Gets the refresh and access tokens for a user that logs in with oauth. ' + operationId: exchangeOauth + tags: [] + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ExchangeTokenResponse' + examples: {} + required: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + headers: {} + '401': + $ref: '#/components/responses/Unauthorized' + description: Authentication failed + '422': + $ref: '#/components/responses/UnprocessableEntity' + description: Validation error – one or more fields failed validation + '500': + $ref: '#/components/responses/InternalServerError' + description: '' + security: [] /auth/oauth/redirect: get: summary: Redirect to OAuth provider @@ -350,11 +384,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SessionResponse' + $ref: '#/components/schemas/ExchangeTokenResponse' example: - message: Login successful. Use token for authentication. - access_token: eyJhbGciOiJIUzI1NiIsI... - refresh_token: _sahjdoDSljjwpioSj1dfhj... + exchange: eyJhbGciOiJIUzI1NiIsI... headers: {} '400': description: OAuth failure (state mismatch, missing token, or missing user info) @@ -474,6 +506,27 @@ components: - Password was changed required: - message + ExchangeTokenResponse: + type: object + properties: + exchange: + type: string + examples: + - sod2_02Jdj... + description: | + The token returned to retrieve the actual login data + required: + - exchange + EmailResendRequest: + type: object + properties: + email: + type: string + format: email + examples: + - test@test.com + required: + - email CreateUserRequest: type: object required: diff --git a/tests/unit/test_google_oauth_service.py b/tests/unit/test_google_oauth_service.py index 6daed61..232c6d7 100644 --- a/tests/unit/test_google_oauth_service.py +++ b/tests/unit/test_google_oauth_service.py @@ -3,45 +3,56 @@ from app.services.google_oauth_service import GoogleOauthService + def test_valid_auth(): user_repo = MagicMock() refresh_repo = MagicMock() + token = { "email": "test@test.com" } + user = MagicMock() user.oauth = "google" - user.id = 123 + user.email = "test@test.com" + user_repo.get_user_by_email.return_value = user service = GoogleOauthService(user_repo, refresh_repo) - with patch("app.services.google_oauth_service.PasswordService.generate_access_token", return_value="token"), \ - patch("app.services.google_oauth_service.PasswordService.generate_refresh_token", return_value="refresh"), \ - patch("app.services.google_oauth_service.PasswordService.hash_refresh_token", return_value="hashed_refresh") as mock_hash: + + with patch( + "app.services.google_oauth_service.PasswordService.generate_exchange_token", + return_value="exchange_token" + ) as mock_exchange: res = service.authenticate_user(token) - mock_hash.assert_called_once_with("refresh") - refresh_repo.create.assert_called_once_with(hashed_token="hashed_refresh", user=user) + mock_exchange.assert_called_once_with("test@test.com") + user_repo.update_user.assert_called_once_with(user) - assert res == ("token", "refresh") + + refresh_repo.create.assert_not_called() + + assert res == "exchange_token" def test_non_existing_user(): user_repo = MagicMock() refresh_repo = MagicMock() + token = {"email": "test@test.com"} user = MagicMock() user.oauth = "google" - user.id = 123 + user.email = "test@test.com" user_repo.get_user_by_email.side_effect = [None, user] service = GoogleOauthService(user_repo, refresh_repo) - with patch("app.services.google_oauth_service.PasswordService.generate_access_token", return_value="token"), \ - patch("app.services.google_oauth_service.PasswordService.generate_refresh_token", return_value="refresh"), \ - patch("app.services.google_oauth_service.PasswordService.hash_refresh_token", return_value="hashed_refresh"): + with patch( + "app.services.google_oauth_service.PasswordService.generate_exchange_token", + return_value="exchange_token" + ) as mock_exchange: res = service.authenticate_user(token) user_repo.create_user.assert_called_once_with( @@ -49,40 +60,162 @@ def test_non_existing_user(): password="", oauth="google" ) + user_repo.update_user.assert_called_once_with(user) - refresh_repo.create.assert_called_once_with(hashed_token="hashed_refresh", user=user) - assert res == ("token", "refresh") + + mock_exchange.assert_called_once_with("test@test.com") + + refresh_repo.create.assert_not_called() + + assert res == "exchange_token" def test_wrong_oauth_method(): user_repo = MagicMock() refresh_repo = MagicMock() + token = {"email": "test@test.com"} user = MagicMock() user.oauth = "local" - user.id = 123 user_repo.get_user_by_email.return_value = user service = GoogleOauthService(user_repo, refresh_repo) + res = service.authenticate_user(token) refresh_repo.create.assert_not_called() + + user_repo.update_user.assert_not_called() + assert res is None def test_wrong_token_format(): user_repo = MagicMock() refresh_repo = MagicMock() + token = {"whatever": "test@test.com"} + service = GoogleOauthService(user_repo, refresh_repo) + + res = service.authenticate_user(token) + + user_repo.get_user_by_email.assert_not_called() + + refresh_repo.create.assert_not_called() + + assert res is None + + +def test_exchange_valid_token(): + user_repo = MagicMock() + refresh_repo = MagicMock() + user = MagicMock() - user.oauth = "google" user.id = 123 + user_repo.get_user_by_email.return_value = user + service = GoogleOauthService(user_repo, refresh_repo) - res = service.authenticate_user(token) + with ( + patch( + "app.services.google_oauth_service.PasswordService.verify_exchange_token", + return_value="test@test.com" + ), + patch( + "app.services.google_oauth_service.PasswordService.generate_access_token", + return_value="access" + ), + patch( + "app.services.google_oauth_service.PasswordService.generate_refresh_token", + return_value="refresh" + ), + patch( + "app.services.google_oauth_service.PasswordService.hash_refresh_token", + return_value="hashed_refresh" + ) as mock_hash + ): + result = service.exchange("exchange_token") + + user_repo.get_user_by_email.assert_called_once_with("test@test.com") + + mock_hash.assert_called_once_with("refresh") + + refresh_repo.create.assert_called_once_with( + hashed_token="hashed_refresh", + user=user + ) + + assert result == ("access", "refresh") + + +def test_exchange_invalid_token(): + user_repo = MagicMock() + refresh_repo = MagicMock() + + service = GoogleOauthService(user_repo, refresh_repo) + + with patch( + "app.services.google_oauth_service.PasswordService.verify_exchange_token", + return_value=None + ): + result = service.exchange("invalid") + + user_repo.get_user_by_email.assert_not_called() refresh_repo.create.assert_not_called() - assert res is None + + assert result is None + + +def test_exchange_user_not_found(): + user_repo = MagicMock() + refresh_repo = MagicMock() + + user_repo.get_user_by_email.return_value = None + + service = GoogleOauthService(user_repo, refresh_repo) + + with patch( + "app.services.google_oauth_service.PasswordService.verify_exchange_token", + return_value="test@test.com" + ): + result = service.exchange("exchange_token") + + refresh_repo.create.assert_not_called() + + assert result is None + + +def test_exchange_refresh_token_generation_failed(): + user_repo = MagicMock() + refresh_repo = MagicMock() + + user = MagicMock() + user.id = 123 + + user_repo.get_user_by_email.return_value = user + + service = GoogleOauthService(user_repo, refresh_repo) + + with ( + patch( + "app.services.google_oauth_service.PasswordService.verify_exchange_token", + return_value="test@test.com" + ), + patch( + "app.services.google_oauth_service.PasswordService.generate_access_token", + return_value="access" + ), + patch( + "app.services.google_oauth_service.PasswordService.generate_refresh_token", + return_value=None + ) + ): + result = service.exchange("exchange_token") + + refresh_repo.create.assert_not_called() + + assert result is None \ No newline at end of file diff --git a/tests/unit/test_password_service.py b/tests/unit/test_password_service.py index b1e1663..1d7736c 100644 --- a/tests/unit/test_password_service.py +++ b/tests/unit/test_password_service.py @@ -79,4 +79,57 @@ def test_hash_refresh_token_different_tokens(): token1 = PasswordService.generate_refresh_token() token2 = PasswordService.generate_refresh_token() - assert PasswordService.hash_refresh_token(token1) != PasswordService.hash_refresh_token(token2) \ No newline at end of file + assert PasswordService.hash_refresh_token(token1) != PasswordService.hash_refresh_token(token2) + +def test_generate_and_verify_exchange_token(): + token = PasswordService.generate_exchange_token("test@test.com") + + email = PasswordService.verify_exchange_token(token) + + assert email == "test@test.com" + + +def test_verify_exchange_token_wrong_type(): + payload = { + "email": "test@test.com", + "type": "access", + "exp": ( + datetime.now(timezone.utc) + timedelta(minutes=1) + ).timestamp() + } + + token = jwt.encode( + payload, + PasswordService.get_secret(), + algorithm="HS256" + ) + + result = PasswordService.verify_exchange_token(token) + + assert result is None + + +def test_verify_exchange_token_invalid(): + result = PasswordService.verify_exchange_token("invalid.token") + + assert result is None + + +def test_verify_exchange_token_expired(): + payload = { + "email": "test@test.com", + "type": "exchange", + "exp": ( + datetime.now(timezone.utc) - timedelta(seconds=1) + ).timestamp() + } + + token = jwt.encode( + payload, + PasswordService.get_secret(), + algorithm="HS256" + ) + + result = PasswordService.verify_exchange_token(token) + + assert result is None \ No newline at end of file From 0ce37dd3b7887722fe65a3e576e4939a9cdadc78 Mon Sep 17 00:00:00 2001 From: Karla Date: Tue, 19 May 2026 20:54:06 +0200 Subject: [PATCH 2/3] Added password reset and logout --- app/__init__.py | 6 +- .../password_reset_token_model.py | 31 +++++ app/database_models/user_model.py | 3 + app/domain_models/password_reset_token.py | 12 ++ .../password_reset_token_repo_protocol.py | 16 +++ .../storage/refresh_token_repo_protocol.py | 2 + .../storage/sql_password_reset_token_repo.py | 44 +++++++ .../storage/sql_refresh_token_repo.py | 6 + app/repositories/storage/sql_user_repo.py | 6 +- app/repositories/units_of_work/deploy_unit.py | 3 + app/repositories/units_of_work/test_unit.py | 3 + app/routes/auth.py | 37 +++++- app/services/auth_service.py | 121 +++++++++++++++++- app/services/password_service.py | 8 ++ app/services/user_service.py | 1 + app/static/makeascene.openapi.yaml | 78 ++++++++++- 16 files changed, 370 insertions(+), 7 deletions(-) create mode 100644 app/database_models/password_reset_token_model.py create mode 100644 app/domain_models/password_reset_token.py create mode 100644 app/repositories/interfaces/storage/password_reset_token_repo_protocol.py create mode 100644 app/repositories/storage/sql_password_reset_token_repo.py diff --git a/app/__init__.py b/app/__init__.py index 0d21603..63d2595 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -25,6 +25,7 @@ from app.database_models.user_model import UserModel from app.database_models.refresh_token_model import RefreshTokenModel from app.database_models.confirm_token_model import ConfirmTokenModel +from app.database_models.password_reset_token_model import PasswordResetTokenModel # Open API file path open_api_file_name = "makeascene.openapi.yaml" @@ -79,7 +80,7 @@ def setup_database(app: Flask): table_obj.create(db.engine) app.logger.info(f"Created table: {table_name}") except Exception as e: - app.logger.error(f"Error creating table {table_name}") + app.logger.error(f"Error creating table {table_name} with error {e}") else: app.logger.info(f"Table {table_name} already exists") @@ -102,7 +103,8 @@ def setup_services(app: Flask): app.user_service = UserService(storage_unit_of_work.user_repo, storage_unit_of_work.email_repo, storage_unit_of_work.confirm_token_repo) app.auth_service = AuthService(storage_unit_of_work.user_repo, storage_unit_of_work.refresh_token_repo, - storage_unit_of_work.confirm_token_repo) + storage_unit_of_work.confirm_token_repo, storage_unit_of_work.email_repo, + storage_unit_of_work.password_reset_token_repo) app.image_service = ImageService(storage_unit_of_work.image_storage, base_url=os.environ.get("BASE_URL", "http://127.0.0.1:5000")) app.google_oauth_service = GoogleOauthService(storage_unit_of_work.user_repo, diff --git a/app/database_models/password_reset_token_model.py b/app/database_models/password_reset_token_model.py new file mode 100644 index 0000000..eed1d29 --- /dev/null +++ b/app/database_models/password_reset_token_model.py @@ -0,0 +1,31 @@ +from datetime import datetime, timezone, timedelta + +from sqlalchemy import Integer, String, DateTime, Boolean, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.extensions import db + + +class PasswordResetTokenModel(db.Model): + __tablename__ = "password_reset_tokens" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc) + timedelta(minutes=15) + ) + revoked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + user = relationship( + "UserModel", back_populates="password_reset_tokens" + ) diff --git a/app/database_models/user_model.py b/app/database_models/user_model.py index eba75d4..ed41c43 100644 --- a/app/database_models/user_model.py +++ b/app/database_models/user_model.py @@ -21,3 +21,6 @@ class UserModel(db.Model): confirm_tokens = relationship( "ConfirmTokenModel", back_populates="user", cascade="all, delete-orphan" ) + password_reset_tokens = relationship( + "PasswordResetTokenModel", back_populates="user", cascade="all, delete-orphan" + ) diff --git a/app/domain_models/password_reset_token.py b/app/domain_models/password_reset_token.py new file mode 100644 index 0000000..6131365 --- /dev/null +++ b/app/domain_models/password_reset_token.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class PasswordResetToken: + id: int + user_id: int + hashed_token: str + created_at: datetime + expires_at: datetime + revoked: bool diff --git a/app/repositories/interfaces/storage/password_reset_token_repo_protocol.py b/app/repositories/interfaces/storage/password_reset_token_repo_protocol.py new file mode 100644 index 0000000..7a85a5c --- /dev/null +++ b/app/repositories/interfaces/storage/password_reset_token_repo_protocol.py @@ -0,0 +1,16 @@ +from typing import Protocol + +from app.domain_models.password_reset_token import PasswordResetToken +from app.domain_models.user import User + + +class PasswordResetTokenRepoProtocol(Protocol): + def __init__(self, session): ... + + def create(self, hashed_token: str, user: User) -> bool: ... + + def get_by_token_hash(self, hashed_token: str) -> PasswordResetToken | None: ... + + def get_by_user(self, user: User) -> list[PasswordResetToken]: ... + + def revoke(self, password_reset_token: PasswordResetToken) -> bool: ... diff --git a/app/repositories/interfaces/storage/refresh_token_repo_protocol.py b/app/repositories/interfaces/storage/refresh_token_repo_protocol.py index 18d8e41..de5744b 100644 --- a/app/repositories/interfaces/storage/refresh_token_repo_protocol.py +++ b/app/repositories/interfaces/storage/refresh_token_repo_protocol.py @@ -11,4 +11,6 @@ def create(self, hashed_token: str, user: User) -> bool: ... def get_by_token_hash(self, hashed_token: str) -> RefreshToken | None: ... + def get_by_user(self, user: User) -> list[RefreshToken]: ... + def revoke(self, refresh_token: RefreshToken) -> bool: ... diff --git a/app/repositories/storage/sql_password_reset_token_repo.py b/app/repositories/storage/sql_password_reset_token_repo.py new file mode 100644 index 0000000..732e805 --- /dev/null +++ b/app/repositories/storage/sql_password_reset_token_repo.py @@ -0,0 +1,44 @@ + +from app.database_models.password_reset_token_model import PasswordResetTokenModel +from app.domain_models.password_reset_token import PasswordResetToken +from app.domain_models.user import User + + +class SqlPasswordResetTokenRepo: + def __init__(self, session): + self.session = session + + def create(self, hashed_token: str, user: User) -> bool: + if self.get_by_token_hash(hashed_token): + return False + db_object = PasswordResetTokenModel(user_id=user.id, token_hash=hashed_token) + self.session.add(db_object) + self.session.commit() + return True + + def get_by_token_hash(self, hashed_token: str) -> PasswordResetToken | None: + db_object = self.session.query(PasswordResetTokenModel).filter_by(token_hash=hashed_token).first() + if not db_object: + return None + return PasswordResetToken( + id=db_object.id, + user_id=db_object.user_id, + hashed_token=db_object.token_hash, + created_at=db_object.created_at, + expires_at=db_object.expires_at, + revoked=db_object.revoked + ) + + def get_by_user(self, user: User) -> list[PasswordResetToken]: + db_list = self.session.query(PasswordResetTokenModel).filter_by(user_id=user.id).all() + return [PasswordResetToken(id=db_object.id, user_id=db_object.user_id, hashed_token=db_object.token_hash, + created_at=db_object.created_at, expires_at=db_object.expires_at, + revoked=db_object.revoked) for db_object in db_list] + + def revoke(self, password_reset_token: PasswordResetToken) -> bool: + db_object = self.session.get(PasswordResetTokenModel, password_reset_token.id) + if not db_object: + return False + db_object.revoked = True + self.session.commit() + return True diff --git a/app/repositories/storage/sql_refresh_token_repo.py b/app/repositories/storage/sql_refresh_token_repo.py index f209269..6043b20 100644 --- a/app/repositories/storage/sql_refresh_token_repo.py +++ b/app/repositories/storage/sql_refresh_token_repo.py @@ -30,6 +30,12 @@ def get_by_token_hash(self, hashed_token: str) -> RefreshToken | None: revoked=db_object.revoked ) + def get_by_user(self, user: User) -> list[RefreshToken]: + db_list = self.session.query(RefreshTokenModel).filter_by(user_id=user.id).all() + return [RefreshToken(id=db_object.id, user_id=db_object.user_id, hashed_token=db_object.token_hash, + created_at=db_object.created_at, expires_at=db_object.expires_at, + revoked=db_object.revoked) for db_object in db_list] + def revoke(self, refresh_token: RefreshToken) -> bool: db_object = self.session.get(RefreshTokenModel, refresh_token.id) if not db_object: diff --git a/app/repositories/storage/sql_user_repo.py b/app/repositories/storage/sql_user_repo.py index da92861..66d03b6 100644 --- a/app/repositories/storage/sql_user_repo.py +++ b/app/repositories/storage/sql_user_repo.py @@ -14,13 +14,15 @@ def get_user(self, user_id: int) -> User | None: db_user = self.session.get(UserModel, user_id) if not db_user: return None - return User(id=db_user.id, hashed_password=db_user.password, oauth=db_user.oauth_method, email=db_user.email) + return User(id=db_user.id, hashed_password=db_user.password, oauth=db_user.oauth_method, email=db_user.email, + confirmed=db_user.confirmed) def get_user_by_email(self, email: str) -> User | None: db_user = self.session.query(UserModel).filter_by(email=email).first() if not db_user: return None - return User(id=db_user.id, hashed_password=db_user.password, oauth=db_user.oauth_method, email=db_user.email, confirmed=db_user.confirmed) + return User(id=db_user.id, hashed_password=db_user.password, oauth=db_user.oauth_method, email=db_user.email, + confirmed=db_user.confirmed) def create_user(self, email: str, password: str, oauth="local") -> bool: if self.get_user_by_email(email): diff --git a/app/repositories/units_of_work/deploy_unit.py b/app/repositories/units_of_work/deploy_unit.py index 939cd1a..ce8a64f 100644 --- a/app/repositories/units_of_work/deploy_unit.py +++ b/app/repositories/units_of_work/deploy_unit.py @@ -3,10 +3,12 @@ from app.repositories.interfaces.external.email_protocol import EmailProtocol from app.repositories.interfaces.storage.confirm_token_repo_protocol import ConfirmTokenRepoProtocol from app.repositories.interfaces.storage.image_storage_protocol import ImageStorageProtocol +from app.repositories.interfaces.storage.password_reset_token_repo_protocol import PasswordResetTokenRepoProtocol from app.repositories.interfaces.storage.refresh_token_repo_protocol import RefreshTokenRepoProtocol from app.repositories.interfaces.storage.user_repo_protocol import UserRepoProtocol from app.repositories.storage.minio_image_storage import MinioImageStorage from app.repositories.storage.sql_confirm_token_repo import SqlConfirmTokenRepo +from app.repositories.storage.sql_password_reset_token_repo import SqlPasswordResetTokenRepo from app.repositories.storage.sql_refresh_token_repo import SqlRefreshTokenRepo from app.repositories.storage.sql_user_repo import SqlUserRepo @@ -21,3 +23,4 @@ def __init__(self): self.refresh_token_repo: RefreshTokenRepoProtocol = SqlRefreshTokenRepo(db.session) self.email_repo: EmailProtocol = ResendEmailRepo() self.confirm_token_repo: ConfirmTokenRepoProtocol = SqlConfirmTokenRepo(db.session) + self.password_reset_token_repo: PasswordResetTokenRepoProtocol = SqlPasswordResetTokenRepo(db.session) diff --git a/app/repositories/units_of_work/test_unit.py b/app/repositories/units_of_work/test_unit.py index 233cd8e..9c28c32 100644 --- a/app/repositories/units_of_work/test_unit.py +++ b/app/repositories/units_of_work/test_unit.py @@ -3,10 +3,12 @@ from app.repositories.interfaces.external.email_protocol import EmailProtocol from app.repositories.interfaces.storage.confirm_token_repo_protocol import ConfirmTokenRepoProtocol from app.repositories.interfaces.storage.image_storage_protocol import ImageStorageProtocol +from app.repositories.interfaces.storage.password_reset_token_repo_protocol import PasswordResetTokenRepoProtocol from app.repositories.interfaces.storage.refresh_token_repo_protocol import RefreshTokenRepoProtocol from app.repositories.interfaces.storage.user_repo_protocol import UserRepoProtocol from app.repositories.storage.mem_image_storage import InMemoryImageStorage from app.repositories.storage.sql_confirm_token_repo import SqlConfirmTokenRepo +from app.repositories.storage.sql_password_reset_token_repo import SqlPasswordResetTokenRepo from app.repositories.storage.sql_refresh_token_repo import SqlRefreshTokenRepo from app.repositories.storage.sql_user_repo import SqlUserRepo @@ -22,3 +24,4 @@ def __init__(self): self.refresh_token_repo: RefreshTokenRepoProtocol = SqlRefreshTokenRepo(db.session) self.email_repo: EmailProtocol = ResendEmailRepo() self.confirm_token_repo: ConfirmTokenRepoProtocol = SqlConfirmTokenRepo(db.session) + self.password_reset_token_repo: PasswordResetTokenRepoProtocol = SqlPasswordResetTokenRepo(db.session) diff --git a/app/routes/auth.py b/app/routes/auth.py index 9247362..c72b5c8 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,7 +1,9 @@ from authlib.integrations.base_client import MismatchingStateError from flask import Blueprint, request, current_app, url_for -from app import validate +from app import validate, login_required +from app.domain_models.user import User +from app.services.password_service import PasswordService auth = Blueprint('auth', __name__) @@ -34,6 +36,15 @@ def login(): }, 201 +@auth.route("/logout", methods=["POST"]) +@validate +@login_required +def logout(user: User): + if current_app.auth_service.logout(user): + return {"message": "Logout successful."}, 200 + return {}, 500 + + @auth.route("/oauth/redirect", methods=["GET"]) @validate def redirect_to(): @@ -107,3 +118,27 @@ def resend_mail(): email = (data.get("email") or "").strip().replace(" ", "") current_app.user_service.resend_confirmation_email(email) return {"message": "Email sent if an unconfirmed user with that email exists."}, 200 + + +@auth.route("/password/request-reset", methods=["POST"]) +@validate +def request_reset_password(): + data = request.get_json() + email = (data.get("email") or "").strip().replace(" ", "") + if current_app.auth_service.request_password_reset(email): + return {"message": "Password reset email sent if an account with that email exists."}, 200 + return {}, 500 + + +@auth.route("/password/reset", methods=["POST"]) +@validate +def reset_password(): + data = request.get_json() + token = data.get("token") + new_password = data.get("new_password") + if current_app.auth_service.reset_password(token, new_password): + return {"message": "Password reset successful."}, 200 + return { + "error": "unauthorized", + "message": "Invalid token." + }, 401 diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 9898ba1..5959764 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -1,18 +1,100 @@ from datetime import datetime, timezone from app.domain_models.user import User +from app.repositories.interfaces.external.email_protocol import EmailProtocol from app.repositories.interfaces.storage.confirm_token_repo_protocol import ConfirmTokenRepoProtocol +from app.repositories.interfaces.storage.password_reset_token_repo_protocol import PasswordResetTokenRepoProtocol from app.repositories.interfaces.storage.refresh_token_repo_protocol import RefreshTokenRepoProtocol from app.repositories.interfaces.storage.user_repo_protocol import UserRepoProtocol from app.services.password_service import PasswordService +import os + + +def _assemble_password_reset_mail(token: str): + # This should point to the frontend. + reset_url = "/v1/auth/password/reset?token=" + base_url = os.environ.get("BASE_URL", "http://127.0.0.1:5000") + + reset_mail_text = f""" + + + + + + Reset your password + + + + + + +
+ + + + +
+

+ Reset your password +

+ +

+ We received a request to reset your password. + Click the button below to choose a new one. +

+ + + Reset Password + + +

+ If you didn’t request a password reset, you can safely ignore this email. +

+
+ +

+ © 2026 Make A Scene +

+
+ + + """ + + return reset_mail_text class AuthService: def __init__(self, user_repo: UserRepoProtocol, refresh_token_repo: RefreshTokenRepoProtocol, - confirm_token_repo: ConfirmTokenRepoProtocol): + confirm_token_repo: ConfirmTokenRepoProtocol, email_repo: EmailProtocol, + password_reset_repo: PasswordResetTokenRepoProtocol): self.repo = user_repo self.refresh_token_repo = refresh_token_repo self.confirm_token_repo = confirm_token_repo + self.email_repo = email_repo + self.password_reset_repo = password_reset_repo def authenticate_local(self, email: str, password: str) -> tuple[str, str] | None: user = self.repo.get_user_by_email(email) @@ -57,6 +139,12 @@ def refresh_session(self, refresh_token: str) -> tuple[str, str] | None: return access_token, new_refresh_token + def logout(self, user: User) -> bool: + refresh_tokens = self.refresh_token_repo.get_by_user(user) + for refresh_token in refresh_tokens: + self.refresh_token_repo.revoke(refresh_token) + return True + def confirm_email(self, token: str) -> bool: token_hash = PasswordService.hash_confirm_token(token) found_token = self.confirm_token_repo.get_by_token_hash(token_hash) @@ -73,3 +161,34 @@ def confirm_email(self, token: str) -> bool: self.repo.update_user(user) self.confirm_token_repo.revoke(found_token) return True + + def request_password_reset(self, email: str) -> bool: + token = PasswordService.generate_reset_token() + hashed_token = PasswordService.hash_reset_token(token) + user = self.repo.get_user_by_email(email) + if not user: + return False + + self.password_reset_repo.create(hashed_token=hashed_token, user=user) + self.email_repo.send_email(subject="Reset your password", body=_assemble_password_reset_mail(token), + recipient=email) + return True + + def reset_password(self, token: str, new_password: str) -> bool: + token_hash = PasswordService.hash_reset_token(token) + found_token = self.password_reset_repo.get_by_token_hash(token_hash) + if not found_token: + return False + if found_token.revoked: + return False + if found_token.expires_at < datetime.now(timezone.utc): + return False + user = self.repo.get_user(found_token.user_id) + if not user: + return False + user.hashed_password = PasswordService.hash_password(new_password) + self.repo.update_user(user) + self.password_reset_repo.revoke(found_token) + return True + + diff --git a/app/services/password_service.py b/app/services/password_service.py index 05fa216..a9dda35 100644 --- a/app/services/password_service.py +++ b/app/services/password_service.py @@ -40,6 +40,10 @@ def generate_refresh_token(): def generate_confirm_token(): return secrets.token_urlsafe(32) + @staticmethod + def generate_reset_token(): + return secrets.token_urlsafe(32) + @staticmethod def hash_refresh_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() @@ -48,6 +52,10 @@ def hash_refresh_token(token: str) -> str: def hash_confirm_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() + @staticmethod + def hash_reset_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + @staticmethod def generate_access_token(user_id: int): payload = { diff --git a/app/services/user_service.py b/app/services/user_service.py index 4e44779..a81289e 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -13,6 +13,7 @@ # For cards, you would have a service that manages cards but also registers them to the user e.g. def _assemble_mail(token: str): + # This should point to the frontend. confirm_url = "/v1/auth/confirm-email?token=" base_url = os.environ.get("BASE_URL", "http://127.0.0.1:5000") confirm_mail_text = f""" diff --git a/app/static/makeascene.openapi.yaml b/app/static/makeascene.openapi.yaml index b5465ad..3560b61 100644 --- a/app/static/makeascene.openapi.yaml +++ b/app/static/makeascene.openapi.yaml @@ -140,7 +140,7 @@ paths: $ref: '#/components/responses/InternalServerError' description: '' security: [] - /auth/password/confirm: + /auth/password/reset: post: summary: Change Password deprecated: false @@ -174,6 +174,73 @@ paths: $ref: '#/components/responses/InternalServerError' description: '' security: [] + /auth/logout: + post: + summary: Logout + deprecated: false + description: Logs a user out. Meaning it invalidates the refresh tokens + operationId: logout + tags: [] + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/LogoutResponse' + example: > + + + + + 404 Not Found + +

Not Found

+ +

The requested URL was not found on the server. If you entered + the URL manually please check your spelling and try again.

+ headers: {} + '401': + $ref: '#/components/responses/Unauthorized' + description: Authentication failed + '422': + $ref: '#/components/responses/UnprocessableEntity' + description: Validation error – one or more fields failed validation + '500': + $ref: '#/components/responses/InternalServerError' + description: '' + security: + - BearerAuth: [] + /auth/password/request-reset: + post: + summary: Request Password Reset + deprecated: false + description: Used to request a password change with an email. + tags: [] + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EmailResendRequest' + examples: {} + required: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/EmailSent' + headers: {} + '422': + $ref: '#/components/responses/UnprocessableEntity' + description: Validation error – one or more fields failed validation + '500': + $ref: '#/components/responses/InternalServerError' + description: '' + security: [] /auth/sessions: post: summary: Login email and password/refresh token @@ -527,6 +594,15 @@ components: - test@test.com required: - email + LogoutResponse: + type: object + properties: + message: + type: string + examples: + - All refresh tokens were invalidated. + required: + - message CreateUserRequest: type: object required: From 12a7fa774cfdc27331029e4536474b6b935eb548 Mon Sep 17 00:00:00 2001 From: Karla Date: Tue, 19 May 2026 20:57:18 +0200 Subject: [PATCH 3/3] Added and fixed test cases --- tests/unit/test_auth_service.py | 183 +++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py index 57293ee..98abd6a 100644 --- a/tests/unit/test_auth_service.py +++ b/tests/unit/test_auth_service.py @@ -8,11 +8,13 @@ # Helpers # --------------------------------------------------------------------------- -def _make_service(user_repo=None, refresh_repo=None, confirm_repo=None): +def _make_service(user_repo=None, refresh_repo=None, confirm_repo=None, email_repo=None, password_reset_repo=None): return AuthService( user_repo or MagicMock(), refresh_repo or MagicMock(), confirm_repo or MagicMock(), + email_repo or MagicMock(), + password_reset_repo or MagicMock() ) @@ -312,4 +314,181 @@ def test_confirm_email_valid(): assert result is True assert user.confirmed is True user_repo.update_user.assert_called_once_with(user) - confirm_repo.revoke.assert_called_once_with(found_token) \ No newline at end of file + confirm_repo.revoke.assert_called_once_with(found_token) + +# --------------------------------------------------------------------------- +# password reset +# --------------------------------------------------------------------------- + +def _valid_reset_token(*, revoked=False, expired=False, user_id=42): + token = MagicMock() + token.revoked = revoked + token.user_id = user_id + token.expires_at = ( + datetime.now(timezone.utc) - timedelta(seconds=1) + if expired + else datetime.now(timezone.utc) + timedelta(days=1) + ) + return token + + +def test_request_password_reset_user_not_found(): + user_repo = MagicMock() + user_repo.get_user_by_email.return_value = None + + service = _make_service(user_repo=user_repo) + service.password_reset_repo = MagicMock() + service.email_repo = MagicMock() + + res = service.request_password_reset("test@test.com") + + assert res is False + service.password_reset_repo.create.assert_not_called() + service.email_repo.send_email.assert_not_called() + + +def test_request_password_reset_success(): + user = MagicMock() + user_repo = MagicMock() + user_repo.get_user_by_email.return_value = user + + service = _make_service(user_repo=user_repo) + service.password_reset_repo = MagicMock() + service.email_repo = MagicMock() + + with patch("app.services.auth_service.PasswordService.generate_reset_token", return_value="plain-token"), \ + patch("app.services.auth_service.PasswordService.hash_reset_token", return_value="hashed-token"), \ + patch("app.services.auth_service._assemble_password_reset_mail", return_value="mail-body"): + + res = service.request_password_reset("test@test.com") + + assert res is True + service.password_reset_repo.create.assert_called_once_with( + hashed_token="hashed-token", + user=user + ) + service.email_repo.send_email.assert_called_once() + + +# --------------------------------------------------------------------------- +# reset_password +# --------------------------------------------------------------------------- + +def test_reset_password_token_not_found(): + user_repo = MagicMock() + reset_repo = MagicMock() + reset_repo.get_by_token_hash.return_value = None + + service = _make_service(user_repo=user_repo) + service.password_reset_repo = reset_repo + + with patch("app.services.auth_service.PasswordService.hash_reset_token", return_value="h"): + res = service.reset_password("token", "new-pass") + + assert res is False + + +def test_reset_password_token_revoked(): + user_repo = MagicMock() + reset_repo = MagicMock() + reset_repo.get_by_token_hash.return_value = _valid_reset_token(revoked=True) + + service = _make_service(user_repo=user_repo) + service.password_reset_repo = reset_repo + + with patch("app.services.auth_service.PasswordService.hash_reset_token", return_value="h"): + res = service.reset_password("token", "new-pass") + + assert res is False + + +def test_reset_password_token_expired(): + user_repo = MagicMock() + reset_repo = MagicMock() + reset_repo.get_by_token_hash.return_value = _valid_reset_token(expired=True) + + service = _make_service(user_repo=user_repo) + service.password_reset_repo = reset_repo + + with patch("app.services.auth_service.PasswordService.hash_reset_token", return_value="h"): + res = service.reset_password("token", "new-pass") + + assert res is False + + +def test_reset_password_user_not_found(): + user_repo = MagicMock() + user_repo.get_user.return_value = None + + reset_token = _valid_reset_token() + reset_repo = MagicMock() + reset_repo.get_by_token_hash.return_value = reset_token + + service = _make_service(user_repo=user_repo) + service.password_reset_repo = reset_repo + + with patch("app.services.auth_service.PasswordService.hash_reset_token", return_value="h"): + res = service.reset_password("token", "new-pass") + + assert res is False + reset_repo.revoke.assert_not_called() + + +def test_reset_password_success(): + user = MagicMock() + + user_repo = MagicMock() + user_repo.get_user.return_value = user + + reset_token = _valid_reset_token() + reset_repo = MagicMock() + reset_repo.get_by_token_hash.return_value = reset_token + + service = _make_service(user_repo=user_repo) + service.password_reset_repo = reset_repo + + with patch("app.services.auth_service.PasswordService.hash_reset_token", return_value="hashed"), \ + patch("app.services.auth_service.PasswordService.hash_password", return_value="new-hash"): + + res = service.reset_password("token", "new-pass") + + assert res is True + assert user.hashed_password == "new-hash" + user_repo.update_user.assert_called_once_with(user) + reset_repo.revoke.assert_called_once_with(reset_token) + + +# --------------------------------------------------------------------------- +# logout +# --------------------------------------------------------------------------- + +def test_logout_multiple_tokens(): + user = MagicMock() + + token1 = MagicMock() + token2 = MagicMock() + + refresh_repo = MagicMock() + refresh_repo.get_by_user.return_value = [token1, token2] + + service = _make_service(refresh_repo=refresh_repo) + + res = service.logout(user) + + assert res is True + refresh_repo.revoke.assert_any_call(token1) + refresh_repo.revoke.assert_any_call(token2) + + +def test_logout_no_tokens(): + user = MagicMock() + + refresh_repo = MagicMock() + refresh_repo.get_by_user.return_value = [] + + service = _make_service(refresh_repo=refresh_repo) + + res = service.logout(user) + + assert res is True + refresh_repo.revoke.assert_not_called() \ No newline at end of file