[#419] API tokens - backend implementation.
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Fabio Manganiello 2024-07-26 02:29:40 +02:00
parent 683ffa98c1
commit 91f6beb349
Signed by: blacklight
GPG key ID: D90FBA7F76362774
6 changed files with 322 additions and 17 deletions

View file

@ -4,9 +4,12 @@ import logging
from flask import Blueprint, request, abort, jsonify 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.exceptions.user import UserException
from platypush.user import UserManager from platypush.user import User, UserManager
from platypush.utils import utcnow from platypush.utils import utcnow
auth = Blueprint('auth', __name__) auth = Blueprint('auth', __name__)
@ -92,6 +95,71 @@ def _session_auth():
return UserAuthStatus.INVALID_CREDENTIALS.to_response() 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(): def _register_route():
"""Registration endpoint""" """Registration endpoint"""
user_manager = UserManager() user_manager = UserManager()
@ -152,6 +220,14 @@ def _auth_get():
if user and session: if user and session:
return _dump_session(session, redirect_page) 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: if status:
return UserAuthStatus.by_status(status).to_response() # type: ignore return UserAuthStatus.by_status(status).to_response() # type: ignore
@ -162,7 +238,10 @@ def _auth_post():
""" """
Authenticate the user session. 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': if auth_type == 'jwt':
return _jwt_auth() return _jwt_auth()
@ -176,7 +255,35 @@ def _auth_post():
return UserAuthStatus.INVALID_AUTH_TYPE.to_response() 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(): def auth_endpoint():
""" """
Authentication endpoint. It validates the user credentials provided over a Authentication endpoint. It validates the user credentials provided over a
@ -213,6 +320,9 @@ def auth_endpoint():
if request.method == 'POST': if request.method == 'POST':
return _auth_post() return _auth_post()
if request.method == 'DELETE':
return _auth_delete()
return UserAuthStatus.INVALID_METHOD.to_response() return UserAuthStatus.INVALID_METHOD.to_response()

View file

@ -58,15 +58,24 @@ def authenticate_token(req) -> Optional[User]:
return None return None
try: 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: except Exception as e:
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
)
# Legacy global token authentication. # Legacy global token authentication.
# The global token should be specified in the configuration file, # The global token should be specified in the configuration file,
# as a root parameter named `token`. # as a root parameter named `token`.
if bool(global_token and user_token == global_token): if bool(global_token and user_token == global_token):
return User(username='__token__', user_id=1) return User(username='__token__', user_id=1)
logger().info('Invalid token: %s', e) logger().info(e)
def authenticate_user_pass(req): def authenticate_user_pass(req):
@ -158,7 +167,7 @@ def authenticate(
# pylint: disable=too-many-return-statements # 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 req, skip_auth_methods=None
) -> Union[User, UserAuthStatus]: ) -> 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 Check against the available authentication methods (except those listed in
``skip_auth_methods``) if the user is properly authenticated. ``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 return UserAuthStatus.OK if isinstance(ret, User) else ret
@ -220,5 +229,5 @@ def current_user() -> Optional[User]:
""" """
Returns the current user if authenticated. 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 return ret if isinstance(ret, User) else None

View file

@ -35,6 +35,12 @@ class UserAuthStatus(Enum):
MISSING_PASSWORD = StatusValue( MISSING_PASSWORD = StatusValue(
400, AuthenticationStatus.MISSING_PASSWORD, 'Missing password' 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( MISSING_USERNAME = StatusValue(
400, AuthenticationStatus.MISSING_USERNAME, 'Missing username' 400, AuthenticationStatus.MISSING_USERNAME, 'Missing username'
) )

View file

@ -13,6 +13,12 @@ class UserException(PlatypushException):
self.user = user self.user = user
class NoUserException(UserException):
"""
Exception raised when no user is found.
"""
class AuthenticationException(UserException): class AuthenticationException(UserException):
""" """
Authentication error exception. Authentication error exception.
@ -40,6 +46,15 @@ class InvalidCredentialsException(AuthenticationException):
super().__init__(error, *args, **kwargs) 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): class InvalidJWTTokenException(InvalidTokenException):
""" """
Exception raised in case of wrong/invalid JWT token. Exception raised in case of wrong/invalid JWT token.

View file

@ -8,20 +8,29 @@ import time
from typing import List, Optional, Tuple, Union from typing import List, Optional, Tuple, Union
import rsa import rsa
from sqlalchemy import and_, or_
from sqlalchemy.orm import make_transient from sqlalchemy.orm import make_transient
from platypush.common.db import Base from platypush.common.db import Base
from platypush.config import Config from platypush.config import Config
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.exceptions.user import ( from platypush.exceptions.user import (
InvalidJWTTokenException,
InvalidCredentialsException, InvalidCredentialsException,
InvalidJWTTokenException,
InvalidTokenException,
NoUserException,
OtpRecordAlreadyExistsException, OtpRecordAlreadyExistsException,
) )
from platypush.utils import get_or_generate_stored_rsa_key_pair, utcnow 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: class UserManager:
@ -646,5 +655,142 @@ class UserManager:
return user_otp, backup_codes 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: ``<username>__<random-string>``).
: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: # vim:sw=4:ts=4:et:

View file

@ -4,6 +4,7 @@ from sqlalchemy import (
Column, Column,
DateTime, DateTime,
ForeignKey, ForeignKey,
Index,
Integer, Integer,
String, String,
UniqueConstraint, UniqueConstraint,
@ -23,6 +24,7 @@ class AuthenticationStatus(enum.Enum):
INVALID_METHOD = 'invalid_method' INVALID_METHOD = 'invalid_method'
INVALID_JWT_TOKEN = 'invalid_jwt_token' INVALID_JWT_TOKEN = 'invalid_jwt_token'
INVALID_OTP_CODE = 'invalid_otp_code' INVALID_OTP_CODE = 'invalid_otp_code'
INVALID_SESSION = 'invalid_session'
MISSING_OTP_CODE = 'missing_otp_code' MISSING_OTP_CODE = 'missing_otp_code'
MISSING_PASSWORD = 'missing_password' MISSING_PASSWORD = 'missing_password'
MISSING_USERNAME = 'missing_username' MISSING_USERNAME = 'missing_username'
@ -87,4 +89,21 @@ class UserBackupCode(Base):
UniqueConstraint(user_id, code) 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: # vim:sw=4:ts=4:et: