diff --git a/platypush/backend/http/app/routes/auth.py b/platypush/backend/http/app/routes/auth.py index 03df68d06d..8563dc6810 100644 --- a/platypush/backend/http/app/routes/auth.py +++ b/platypush/backend/http/app/routes/auth.py @@ -4,9 +4,12 @@ import logging from flask import Blueprint, request, abort, jsonify -from platypush.backend.http.app.utils.auth import UserAuthStatus +from platypush.backend.http.app.utils.auth import ( + UserAuthStatus, + get_current_user_or_auth_status, +) from platypush.exceptions.user import UserException -from platypush.user import UserManager +from platypush.user import User, UserManager from platypush.utils import utcnow auth = Blueprint('auth', __name__) @@ -92,6 +95,71 @@ def _session_auth(): return UserAuthStatus.INVALID_CREDENTIALS.to_response() +def _create_token(): + payload = {} + try: + payload = json.loads(request.get_data(as_text=True)) + except json.JSONDecodeError: + pass + + user = None + username = payload.get('username') + password = payload.get('password') + code = payload.get('code') + name = payload.get('name') + expiry_days = payload.get('expiry_days') + user_manager = UserManager() + response = get_current_user_or_auth_status(request) + + # Try and authenticate with the credentials passed in the JSON payload + if username and password: + user = user_manager.authenticate_user(username, password, code=code) + if not isinstance(user, User): + return UserAuthStatus.INVALID_CREDENTIALS.to_response() + + if not user: + if not (response and isinstance(response, User)): + return response.to_response() + + user = response + + expires_at = None + if expiry_days: + expires_at = datetime.datetime.now() + datetime.timedelta(days=expiry_days) + + try: + token = UserManager().generate_api_token( + username=str(user.username), name=name, expires_at=expires_at + ) + return jsonify({'token': token}) + except UserException: + return UserAuthStatus.INVALID_CREDENTIALS.to_response() + + +def _delete_token(): + try: + payload = json.loads(request.get_data(as_text=True)) + token = payload.get('token') + assert token + except (AssertionError, json.JSONDecodeError): + return UserAuthStatus.INVALID_TOKEN.to_response() + + user_manager = UserManager() + + try: + token = payload.get('token') + if not token: + return UserAuthStatus.INVALID_TOKEN.to_response() + + ret = user_manager.delete_api_token(token) + if not ret: + return UserAuthStatus.INVALID_TOKEN.to_response() + + return jsonify({'status': 'ok'}) + except UserException: + return UserAuthStatus.INVALID_CREDENTIALS.to_response() + + def _register_route(): """Registration endpoint""" user_manager = UserManager() @@ -152,6 +220,14 @@ def _auth_get(): if user and session: return _dump_session(session, redirect_page) + response = get_current_user_or_auth_status(request) + if isinstance(response, User): + user = response + return jsonify({'status': 'ok', 'user_id': user.id, 'username': user.username}) + + if response: + status = response + if status: return UserAuthStatus.by_status(status).to_response() # type: ignore @@ -162,7 +238,10 @@ def _auth_post(): """ Authenticate the user session. """ - auth_type = request.args.get('type') or 'jwt' + auth_type = request.args.get('type') or 'token' + + if auth_type == 'token': + return _create_token() if auth_type == 'jwt': return _jwt_auth() @@ -176,7 +255,35 @@ def _auth_post(): return UserAuthStatus.INVALID_AUTH_TYPE.to_response() -@auth.route('/auth', methods=['GET', 'POST']) +def _auth_delete(): + """ + Logout/invalidate a token or the current user session. + """ + # Delete the specified API token if it's passed on the JSON payload + token = None + try: + payload = json.loads(request.get_data(as_text=True)) + token = payload.get('token') + except json.JSONDecodeError: + pass + + if token: + return _delete_token() + + user_manager = UserManager() + session_token = request.cookies.get('session_token') + redirect_page = request.args.get('redirect') or '/' + + if session_token: + user, session = user_manager.authenticate_user_session(session_token)[:2] + if user and session: + user_manager.delete_user_session(session_token) + return jsonify({'status': 'ok', 'redirect': redirect_page}) + + return UserAuthStatus.INVALID_SESSION.to_response() + + +@auth.route('/auth', methods=['GET', 'POST', 'DELETE']) def auth_endpoint(): """ Authentication endpoint. It validates the user credentials provided over a @@ -213,6 +320,9 @@ def auth_endpoint(): if request.method == 'POST': return _auth_post() + if request.method == 'DELETE': + return _auth_delete() + return UserAuthStatus.INVALID_METHOD.to_response() diff --git a/platypush/backend/http/app/utils/auth/__init__.py b/platypush/backend/http/app/utils/auth/__init__.py index b57a61e5e3..bcddd34f3c 100644 --- a/platypush/backend/http/app/utils/auth/__init__.py +++ b/platypush/backend/http/app/utils/auth/__init__.py @@ -58,15 +58,24 @@ def authenticate_token(req) -> Optional[User]: return None try: - return user_manager.validate_jwt_token(user_token) + # Stantard API token authentication + return user_manager.validate_api_token(user_token) except Exception as e: - # Legacy global token authentication. - # The global token should be specified in the configuration file, - # as a root parameter named `token`. - if bool(global_token and user_token == global_token): - return User(username='__token__', user_id=1) + try: + # Legacy JWT token authentication + return user_manager.validate_jwt_token(user_token) + except Exception as ee: + logger().debug( + 'Invalid token. API token error: %s, JWT token error: %s', e, ee + ) - logger().info('Invalid token: %s', e) + # Legacy global token authentication. + # The global token should be specified in the configuration file, + # as a root parameter named `token`. + if bool(global_token and user_token == global_token): + return User(username='__token__', user_id=1) + + logger().info(e) def authenticate_user_pass(req): @@ -158,7 +167,7 @@ def authenticate( # pylint: disable=too-many-return-statements -def _get_current_user_or_auth_status( +def get_current_user_or_auth_status( req, skip_auth_methods=None ) -> Union[User, UserAuthStatus]: """ @@ -212,7 +221,7 @@ def get_auth_status(req, skip_auth_methods=None) -> UserAuthStatus: Check against the available authentication methods (except those listed in ``skip_auth_methods``) if the user is properly authenticated. """ - ret = _get_current_user_or_auth_status(req, skip_auth_methods=skip_auth_methods) + ret = get_current_user_or_auth_status(req, skip_auth_methods=skip_auth_methods) return UserAuthStatus.OK if isinstance(ret, User) else ret @@ -220,5 +229,5 @@ def current_user() -> Optional[User]: """ Returns the current user if authenticated. """ - ret = _get_current_user_or_auth_status(request) + ret = get_current_user_or_auth_status(request) return ret if isinstance(ret, User) else None diff --git a/platypush/backend/http/app/utils/auth/status.py b/platypush/backend/http/app/utils/auth/status.py index 5c3d0d75a6..fc3a3e0a6f 100644 --- a/platypush/backend/http/app/utils/auth/status.py +++ b/platypush/backend/http/app/utils/auth/status.py @@ -35,6 +35,12 @@ class UserAuthStatus(Enum): MISSING_PASSWORD = StatusValue( 400, AuthenticationStatus.MISSING_PASSWORD, 'Missing password' ) + INVALID_SESSION = StatusValue( + 401, AuthenticationStatus.INVALID_CREDENTIALS, 'Invalid session' + ) + INVALID_TOKEN = StatusValue( + 400, AuthenticationStatus.INVALID_JWT_TOKEN, 'Invalid token' + ) MISSING_USERNAME = StatusValue( 400, AuthenticationStatus.MISSING_USERNAME, 'Missing username' ) diff --git a/platypush/exceptions/user.py b/platypush/exceptions/user.py index 2ea59105db..1fc550246d 100644 --- a/platypush/exceptions/user.py +++ b/platypush/exceptions/user.py @@ -13,6 +13,12 @@ class UserException(PlatypushException): self.user = user +class NoUserException(UserException): + """ + Exception raised when no user is found. + """ + + class AuthenticationException(UserException): """ Authentication error exception. @@ -40,6 +46,15 @@ class InvalidCredentialsException(AuthenticationException): super().__init__(error, *args, **kwargs) +class InvalidTokenException(InvalidTokenException): + """ + Exception raised in case of wrong/invalid API token. + """ + + def __init__(self, error='Invalid API token', *args, **kwargs): + super().__init__(error, *args, **kwargs) + + class InvalidJWTTokenException(InvalidTokenException): """ Exception raised in case of wrong/invalid JWT token. diff --git a/platypush/user/__init__.py b/platypush/user/__init__.py index 7c416385e9..e5f6bd9966 100644 --- a/platypush/user/__init__.py +++ b/platypush/user/__init__.py @@ -8,20 +8,29 @@ import time from typing import List, Optional, Tuple, Union import rsa - +from sqlalchemy import and_, or_ from sqlalchemy.orm import make_transient from platypush.common.db import Base from platypush.config import Config from platypush.context import get_plugin from platypush.exceptions.user import ( - InvalidJWTTokenException, InvalidCredentialsException, + InvalidJWTTokenException, + InvalidTokenException, + NoUserException, OtpRecordAlreadyExistsException, ) from platypush.utils import get_or_generate_stored_rsa_key_pair, utcnow -from ._model import User, UserSession, UserOtp, UserBackupCode, AuthenticationStatus +from ._model import ( + AuthenticationStatus, + User, + UserBackupCode, + UserOtp, + UserSession, + UserToken, +) class UserManager: @@ -646,5 +655,142 @@ class UserManager: return user_otp, backup_codes + def generate_api_token( + self, + username: str, + name: Optional[str] = None, + expires_at: Optional[datetime.datetime] = None, + ) -> str: + """ + Create a random API token for a user. + + These are randomly generated tokens stored encrypted in the server's + database. They are recommended for API usage over JWT tokens because: + + 1. JWT tokens rely on the user's password and are invalidated if + the user changes the password. + 2. JWT tokens are stateless and can't be revoked once generated - + unless the user changes the password. + 3. They can end up exposing either the user's password, the + server's keys, or both, if not handled properly. + + :param username: User name. + :param name: Name of the token (default: ``__``). + :param expires_at: Expiration datetime of the token. + :return: The generated token as a string. + :raises: :class:`platypush.exceptions.user.NoUserException` if the user + does not exist. + """ + user = self.get_user(username) + if not user: + raise NoUserException() + + token = ( + username + + ':' + + ''.join( + random.choice( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._" + ) + for _ in range(32) + ) + ) + + name = name or f'{username}__' + ''.join( + random.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") for _ in range(8) + ) + + with self._get_session() as session: + user_token = UserToken( + user_id=user.user_id, + name=name, + token=self._encrypt_password( + token, user.password_salt, user.hmac_iterations + ), + created_at=utcnow(), + expires_at=expires_at, + ) + + session.add(user_token) + session.commit() + + return token + + @staticmethod + def _user_by_token(session, token: str) -> Optional[User]: + username = token.split(':')[0] + return session.query(User).filter_by(username=username).first() + + def validate_api_token(self, token: str) -> User: + """ + Validate an API token. + + :param token: Token to validate. + :return: On success, it returns the user associated to the token. + :raises: :class:`platypush.exceptions.user.InvalidTokenException` in + case of invalid token. + """ + with self._get_session() as session: + user = self._user_by_token(session, token) + if not user: + raise InvalidTokenException() + + encrypted_token = self._encrypt_password( + token, user.password_salt, user.hmac_iterations # type: ignore + ) + + user_token = ( + session.query(UserToken) + .filter( + and_( + UserToken.token == encrypted_token, + UserToken.user_id == user.user_id, + or_( + UserToken.expires_at.is_(None), + UserToken.expires_at >= utcnow(), + ), + ) + ) + .first() + ) + + if not user_token: + raise InvalidTokenException() + + return user + + def delete_api_token(self, token: str) -> bool: + """ + Delete an API token. + + :param token: Token to delete. + :return: True if the token was successfully deleted, False otherwise. + """ + with self._get_session() as session: + user = self._user_by_token(session, token) + if not user: + return False + + encrypted_token = self._encrypt_password( + token, user.password_salt, user.hmac_iterations # type: ignore + ) + + user_token = ( + session.query(UserToken) + .filter_by( + token=encrypted_token, + user_id=user.user_id, + ) + .first() + ) + + if not user_token: + return False + + session.delete(user_token) + session.commit() + + return True + # vim:sw=4:ts=4:et: diff --git a/platypush/user/_model.py b/platypush/user/_model.py index 2bc6f35908..b96f648455 100644 --- a/platypush/user/_model.py +++ b/platypush/user/_model.py @@ -4,6 +4,7 @@ from sqlalchemy import ( Column, DateTime, ForeignKey, + Index, Integer, String, UniqueConstraint, @@ -23,6 +24,7 @@ class AuthenticationStatus(enum.Enum): INVALID_METHOD = 'invalid_method' INVALID_JWT_TOKEN = 'invalid_jwt_token' INVALID_OTP_CODE = 'invalid_otp_code' + INVALID_SESSION = 'invalid_session' MISSING_OTP_CODE = 'missing_otp_code' MISSING_PASSWORD = 'missing_password' MISSING_USERNAME = 'missing_username' @@ -87,4 +89,21 @@ class UserBackupCode(Base): UniqueConstraint(user_id, code) +class UserToken(Base): + """Models the UserToken table""" + + __tablename__ = 'user_token' + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey('user.user_id'), nullable=False) + token = Column(String, unique=True, nullable=False) + name = Column(String, nullable=False) + created_at = Column(DateTime) + expires_at = Column(DateTime) + + Index('user_token_user_id_token_idx', user_id, token, unique=True) + Index('user_token_user_id_name_idx', user_id, name, unique=True) + Index('user_token_user_id_expires_at_idx', user_id, token, expires_at) + + # vim:sw=4:ts=4:et: