[#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 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()
|
||||
|
||||
|
||||
|
|
|
@ -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:
|
||||
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.
|
||||
# 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('Invalid token: %s', e)
|
||||
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
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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: ``<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:
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue