[#419] API tokens - backend implementation.
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
683ffa98c1
commit
91f6beb349
6 changed files with 322 additions and 17 deletions
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Loading…
Reference in a new issue