Commit 7bdc24d8 authored by David Seaward's avatar David Seaward Committed by Gogs
Browse files

Merge branch 'int_collection_field' of david.seaward/purist_middleware into master

parents 644b07ef 5c8cb10a
......@@ -25,7 +25,6 @@ Prerequisites
* [REST API enabled](https://docs.woocommerce.com/document/woocommerce-rest-api/)
* [WooCommerce Subscriptions](https://woocommerce.com/products/woocommerce-subscriptions/)
* [JWT Authentication for WP REST API](https://wordpress.org/plugins/jwt-authentication-for-wp-rest-api/)
(pending)
* OpenVPN instance (SSH access)
* Including [openvpn-confgen](https://plan.puri.st/module/openvpn_confgen)
* Typically, the Nginx user (`www-data`) will need SSH access
......@@ -88,16 +87,24 @@ Setup
* `sudo service nginx restart`
* `sudo service supervisor restart`
* Check logs:
* `/var/log/uwsgi/emperor.log`
* `/var/log/uwsgi/app/purist_middleware.log`
* `/var/log/nginx/error.log`
* `/var/log/nginx/access.log`
* `/var/log/supervisor/supervisord.log`
* `/var/log/nginx/error.log`
* `/var/log/purist/middleware/beat.log`
* `/var/log/supervisor/supervisord.log`
* `/var/log/uwsgi/emperor.log`
* `/var/log/uwsgi/app/purist_middleware.log`
For more options and details see
<https://docs.djangoproject.com/en/1.11/#the-development-process>
Configure
---------
* Log in to admin interface as superuser
* Define intervals in Django_Celery_Beat > Intervals
* Define periodic tasks in Django_Celery_Beat > Periodic tasks
* Define known products in Limit Monitor > External bundles
Update
------
......
......@@ -11,6 +11,7 @@ DEBUG=True
DEBUG_ALL_ACCESS=True
DEBUG_CHANGE_PASSWORD=False
DEBUG_SKIP_ACTIVATION_COMMAND=True
DEBUG_SKIP_VALIDATE_ON_AUTHENTICATION = False
# change to false after initial setup
ALLOWED_HOSTS=localhost
STATIC_ROOT=/var/opt/purist/middleware/static
......@@ -28,11 +29,10 @@ STATICFILES_DIRS=/var/opt/purist/brand,/var/opt/purist/downloads
WOO_URL=https://example.com
WOO_WP_API=True
WOO_VERSION=wc/v1
WOO_PRODUCT_LIST=123,124
WOO1_FIELD_LIST=Existing username,Username
WOO_QUERY_STRING_AUTH = True
WOOSUB1_PRODUCT_LIST = 123,124
OVPN_HOSTNAME=ssh.example.com
OVPN_PORT=22
OVPN_USERNAME=username
OVPN_FILEPATH="/path/to/{IDENTITY}/{IDENTITY}.ovpn"
SUBSCRIPTION_LINK=https://www.example.com
......@@ -69,7 +69,7 @@ SPDX-License-Identifier: AGPL-3.0
{% for limit in limits %}
<tr>
<th>{{ limit.service_label() }}</th>
<th class="row_header">{{ limit.service_label() }}</th>
<td>{{ limit.active_label() }}</td>
<td title="{{ limit.endpoint_full_label() }}">{{ limit.endpoint_short_label() }}</td>
<td>{{ limit.credit_label() }}</td>
......@@ -80,15 +80,6 @@ SPDX-License-Identifier: AGPL-3.0
</table>
<h2>{% trans "Profile management" %}</h2>
<ul>
{% if DEBUG_CHANGE_PASSWORD %}
<li><a href="{% url 'auth_password_change' %}">{% trans "Change password" %}</a></li>
<li><a href="{{ link_subscription }}" target="_blank">{% trans "Manage subscriptions" %}</a></li>
{% endif %}
</ul>
<h2>{% trans "Downloads" %}</h2>
<ul>
......@@ -103,6 +94,14 @@ SPDX-License-Identifier: AGPL-3.0
{% endif %}
</ul>
{% if DEBUG_CHANGE_PASSWORD %}
<h2>{% trans "Profile management" %}</h2>
<ul>
<li><a href="{% url 'auth_password_change' %}">{% trans "Change password" %}</a></li>
</ul>
{% endif %}
</article>
<nav>
......
......@@ -17,25 +17,25 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='externalbundle',
name='parser',
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.ExternalParser(0),
enum=limitmonitor.models.ExternalParser),
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Legacy(0),
enum=limitmonitor.models.Legacy),
),
migrations.AlterField(
model_name='externalbundle',
name='service',
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Service(0),
enum=limitmonitor.models.Service),
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Legacy(0),
enum=limitmonitor.models.Legacy),
),
migrations.AlterField(
model_name='externalcredit',
name='parser',
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.ExternalParser(0),
enum=limitmonitor.models.ExternalParser),
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Legacy(0),
enum=limitmonitor.models.Legacy),
),
migrations.AlterField(
model_name='limit',
name='service',
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Service(0),
enum=limitmonitor.models.Service),
field=choicesenum.django.fields.EnumIntegerField(default=limitmonitor.models.Legacy(0),
enum=limitmonitor.models.Legacy),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2018-01-09 11:39
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('limitmonitor', '0003_auto_20171208_1327'),
]
operations = [
migrations.AlterField(
model_name='externalbundle',
name='service',
field=models.IntegerField(choices=[(0, 'Undefined')], default=0),
),
migrations.AlterField(
model_name='limit',
name='service',
field=models.IntegerField(choices=[(0, 'Undefined')], default=0),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2018-01-09 12:11
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('limitmonitor', '0004_auto_20180109_1139'),
]
operations = [
migrations.AlterField(
model_name='externalbundle',
name='parser',
field=models.IntegerField(choices=[(0, 'Undefined')], default=0),
),
migrations.AlterField(
model_name='externalcredit',
name='parser',
field=models.IntegerField(choices=[(0, 'Undefined')], default=0),
),
]
from choicesenum import ChoicesEnum
from choicesenum.django.fields import EnumIntegerField
from django.conf import settings
from django.db import models
from django.utils import timezone
class ExternalParser(ChoicesEnum):
class Legacy(ChoicesEnum):
UNDEFINED = 0, "Undefined"
WOO1 = 1, "WooCommerce v1"
WOOSUB1 = 2, "WooCommerce Subscription v1"
class Service(ChoicesEnum):
UNDEFINED = 0, "Undefined"
TUNNEL = 1, "Tunnel"
COMMUNICATION = 2, "Communication"
def create_missing_user_limits(user):
for code, label in Service.choices():
is_undefined = code == Service.UNDEFINED.value
for code in settings.LM_SERVICES.MAP.keys():
is_defined = code != settings.LM_SERVICES.UNDEFINED
is_exists = Limit.objects.filter(user=user, service=code).exists()
if not is_undefined and not is_exists:
if is_defined and not is_exists:
Limit(user=user, service=code).save()
class Limit(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL)
service = EnumIntegerField(enum=Service, default=Service.UNDEFINED)
service = models.IntegerField(default=settings.LM_SERVICES.UNDEFINED,
choices=settings.LM_SERVICES.choices())
renewal_date = models.DateTimeField(default=None, blank=True, null=True)
expiry_date = models.DateTimeField(default=None, blank=True, null=True)
volume_total = models.DecimalField(default=0, decimal_places=2, max_digits=6)
......@@ -38,12 +30,7 @@ class Limit(models.Model):
def service_label(self):
label = "None"
for key, value in Service.choices():
if self.service == key:
label = value
return label
return settings.LM_SERVICES.get_name_by_code(self.service)
def active_label(self):
......@@ -77,7 +64,7 @@ class Limit(models.Model):
def credit_label(self):
if self.service == Service.TUNNEL:
if self.service == settings.LM_SERVICES.TUNNEL:
return self.days_credit_label()
else:
return self.days_credit_label()
......@@ -118,9 +105,11 @@ class Limit(models.Model):
class ExternalBundle(models.Model):
parser = EnumIntegerField(enum=ExternalParser, default=ExternalParser.UNDEFINED)
parser = models.IntegerField(default=settings.LM_PARSERS.UNDEFINED,
choices=settings.LM_PARSERS.choices())
external_key = models.CharField(max_length=30)
service = EnumIntegerField(enum=Service, default=Service.UNDEFINED)
service = models.IntegerField(default=settings.LM_SERVICES.UNDEFINED,
choices=settings.LM_SERVICES.choices())
time_credit = models.DecimalField(default=0, decimal_places=2, max_digits=6)
volume_credit = models.DecimalField(default=0, decimal_places=2, max_digits=6)
created_date = models.DateTimeField(default=timezone.now)
......@@ -128,7 +117,8 @@ class ExternalBundle(models.Model):
class ExternalCredit(models.Model):
parser = EnumIntegerField(enum=ExternalParser, default=ExternalParser.UNDEFINED)
parser = models.IntegerField(default=settings.LM_PARSERS.UNDEFINED,
choices=settings.LM_PARSERS.choices())
external_key = models.CharField(max_length=30)
label = models.CharField(max_length=30)
bundle_key = models.CharField(max_length=30)
......@@ -141,13 +131,17 @@ class ExternalCredit(models.Model):
created_date = models.DateTimeField(default=timezone.now)
updated_date = models.DateTimeField(default=timezone.now)
@property
def parser_name(self):
return settings.LM_PARSERS.get_name_by_code(self.parser)
@property
def external_code(self):
return str(self.parser) + ":" + str(self.external_key)
return self.parser_name + ":" + str(self.external_key)
@property
def external_bundle(self):
return str(self.parser) + ":" + str(self.external_key)
return self.parser_name + ":" + str(self.external_key)
class Credit(models.Model):
......
......@@ -8,7 +8,7 @@ from django.db import transaction
from django.utils import timezone
from woocommerce import API as WOO_API
from ..models import ExternalCredit, ExternalBundle, Limit, Service
from ..models import ExternalCredit, ExternalBundle, Limit
logger = get_task_logger(__name__)
......@@ -20,9 +20,25 @@ def get_woo_connection():
consumer_secret=settings.WOO_CONSUMER_SECRET,
wp_api=settings.WOO_WP_API,
version=settings.WOO_VERSION,
query_string_auth=settings.WOO_QUERY_STRING_AUTH,
)
def get_username_from_woo_customer_id(customer_id, woo=None):
if woo is None:
woo = get_woo_connection()
try:
query = "customers/" + str(customer_id)
result = woo.get(query).json()
return result["username"] + "@" + settings.SITE_DOMAIN
except Exception as e:
logger.exception("Could not retrieve username for customer_id " + str(customer_id))
return "invalid"
return account
def get_openvpn_ssh_connection():
# make ssh connection to OpenVPN server
# (uses system host keys, warns if host is not recognised)
......@@ -101,7 +117,7 @@ def activate(ssh, limit, credit_timedelta=None, renewal_date=None):
# otherwise, activate the limit before saving
if limit.service == Service.TUNNEL:
if limit.service == settings.LM_SERVICES.TUNNEL:
user_identity = limit.user.get_identity()
filepath = settings.OVPN_FILEPATH.replace("{USER_IDENTITY}", user_identity)
is_file = pathlib.Path(filepath).is_file()
......@@ -117,31 +133,13 @@ def activate(ssh, limit, credit_timedelta=None, renewal_date=None):
def deactivate(ssh, limit):
if limit.service == Service.TUNNEL:
if limit.service == settings.LM_SERVICES.TUNNEL:
managed_exec(ssh, "./create_new_ovpn_config --revoke %s" % (limit.user.get_identity(),))
limit.is_active = False
limit.save()
def get_account_from_woo_meta(meta_list):
account = "invalid"
for meta_item in meta_list:
if meta_item["key"] in settings.WOO1_FIELD_LIST: # for example, "Existing username,"
account = meta_item["value"]
at_count = account.count("@")
if at_count == 0: # nodomain
account += "@" + settings.SITE_DOMAIN # corrected to nodomain@example.com
elif at_count == 1:
pass # valid account format
else: # at_count > 1, for example bad@user@example.com
account = account.replace("@", ".AT.") # force invalid name bad.AT.user.AT.example.com
return account
def get_limit_objects(credit):
# get and validate local username
......@@ -174,22 +172,22 @@ def store_credit_and_update_limit(ssh, credit, next_renewal=None):
if credit.parser == "WOO1":
from .tunnel_credit import update_limit_woo1
update_limit_woo1(ssh, credit)
elif credit.parser == "WOOSUB1":
elif credit.parser == 2: # FIXME: this is WOO_SUBSCRIPTION_V1 from purist.limitmonitor
from .tunnel_subscription import update_limit_woosub1
update_limit_woosub1(ssh, credit, next_renewal)
else:
raise Exception("Unrecognised parser " + credit.parser)
raise Exception("Unrecognised Parser " + str(credit.parser))
credit.is_converted = True
credit.error_message = ""
except Exception as e:
message = "Skipped adding credit " + credit.parser + ":" + credit.external_key + ". "
message = "Skipped adding credit Parser " + str(credit.parser) + ": " + credit.external_key + ". "
logger.exception(message)
credit.error_message = message + repr(e)
finally:
credit.save()
state = "converted" if credit.is_converted else "skipped"
logger.info("Stored " + state + " credit " + credit.parser + ":" + credit.external_key)
logger.info("Stored " + state + " credit Parser " + str(credit.parser) + ": " + credit.external_key)
def deactivate_all_expired_limits():
......
......@@ -17,7 +17,7 @@ def parse_woo1(json_entry, product_id):
product_id = line_item["product_id"]
product_label = line_item["name"]
quantity = line_item["quantity"]
account = get_account_from_woo_meta(line_item["meta"])
account = None # FIXME: need to get the account
external_key = str(order_id) + ":" + str(item_id)
external_label = str(order_name)
......
......@@ -23,16 +23,19 @@ def parse_woosub1(json_entry):
next_renewal_naive = datetime.datetime.strptime(json_entry["next_payment_date"], "%Y-%m-%dT%H:%M:%S")
next_renewal = timezone.make_aware(next_renewal_naive)
# get account name
username = get_username_from_woo_customer_id(json_entry["customer_id"])
# create result
result = {
"parser": "WOOSUB1",
"parser": 2, # FIXME: this is WOO_SUBSCRIPTION_V1 from purist.limitmonitor
"external_key": subscription_id,
"label": json_entry["number"],
"bundle_key": str(line_item["product_id"]),
"bundle_label": str(line_item["name"]),
"quantity": 1,
"account": get_account_from_woo_meta(line_item["meta"]),
"account": username,
"next_renewal": next_renewal,
}
......@@ -52,7 +55,9 @@ def monitor_woosub1_new_subscriptions():
# parse recent subscriptions and store results
for json_entry in latest_subscription_json:
try:
result_list.extend(parse_woosub1(json_entry))
product_id = int(json_entry["line_items"][0]["product_id"])
if product_id in settings.WOOSUB1_PRODUCT_LIST:
result_list.extend(parse_woosub1(json_entry))
except Exception as e:
logger.exception("Skipping JSON entry " + str(json_entry))
......
......@@ -15,7 +15,8 @@ def userlimit(request):
has_limit = {}
none_limit = True
for limit in limits:
has_limit[limit.service] = limit.is_active
label = limit.service_label().upper()
has_limit[label] = limit.is_active
if limit.is_active:
none_limit = False
......@@ -31,7 +32,6 @@ def userlimit(request):
"site_provider_link": settings.SITE_PROVIDER_LINK,
"limits": limits,
"has_limit": has_limit,
"link_subscription": settings.LINK_SUBSCRIPTION,
"link_profile_ordered_dict": settings.LINK_PROFILE_ORDERED_DICT,
}
......
......@@ -3,6 +3,7 @@ import strictyaml
from decouple import Config, Csv, RepositoryIni
from django_auth_ldap.config import LDAPSearch
import purist.limitmonitor
from .settings_original import *
#
......@@ -28,9 +29,12 @@ SECRET_KEY = secret_config("DJANGO_SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = config("DEBUG", cast=bool)
# it is safe to use these flags in production
DEBUG_ALL_ACCESS = config("DEBUG_ALL_ACCESS", cast=bool)
DEBUG_CHANGE_PASSWORD = config("DEBUG_CHANGE_PASSWORD", cast=bool)
DEBUG_SKIP_ACTIVATION_COMMAND = config("DEBUG_SKIP_ACTIVATION_COMMAND", cast=bool)
DEBUG_SKIP_VALIDATE_ON_AUTHENTICATION = config("DEBUG_SKIP_VALIDATE_ON_AUTHENTICATION", cast=bool)
# Required if DEBUG is False
ALLOWED_HOSTS = config("ALLOWED_HOSTS", cast=Csv())
......@@ -133,8 +137,6 @@ SITE_DOMAIN = config("SITE_DOMAIN")
SITE_PROVIDER = config("SITE_PROVIDER")
SITE_PROVIDER_LINK = config("SITE_PROVIDER_LINK")
LINK_SUBSCRIPTION = config("LINK_SUBSCRIPTION")
#
# WOOCOMMERCE
#
......@@ -142,17 +144,16 @@ LINK_SUBSCRIPTION = config("LINK_SUBSCRIPTION")
WOO_URL = config("WOO_URL")
WOO_WP_API = config("WOO_WP_API", cast=bool)
WOO_VERSION = config("WOO_VERSION")
WOO_QUERY_STRING_AUTH = config("WOO_QUERY_STRING_AUTH", cast=bool) # required for OAuth over HTTPS
WOO_CONSUMER_KEY = secret_config("WOO_CONSUMER_KEY")
WOO_CONSUMER_SECRET = secret_config("WOO_CONSUMER_SECRET")
WOO_PRODUCT_LIST = config("WOO_PRODUCT_LIST", cast=Csv(int))
#
# WOO1 PARSER
# WOOSUB1 PARSER
#
WOO1_FIELD_LIST = config("WOO1_FIELD_LIST", cast=Csv())
WOOSUB1_PRODUCT_LIST = config("WOOSUB1_PRODUCT_LIST", cast=Csv(int))
#
# SSH CONNECTION TO OPENVPN SERVER
......@@ -163,6 +164,13 @@ OVPN_PORT = config("OVPN_PORT", cast=int)
OVPN_USERNAME = config("OVPN_USERNAME")
OVPN_FILEPATH = config("OVPN_FILEPATH")
#
# LIMIT MONITOR
#
LM_SERVICES = purist.limitmonitor.ServicesContainer
LM_PARSERS = purist.limitmonitor.ParserContainer
#
# LOGGING
#
......
import logging
from django.conf import settings
from django.contrib.auth.password_validation import MinimumLengthValidator as BaseValidator
from django.core import validators
from django.utils.deconstruct import deconstructible
from django.utils.translation import ugettext_lazy as _
from django_auth_ldap.backend import LDAPBackend as BaseBackend
from woocommerce import API as WOOCOMMERCE_API
from .models import User, UsernameValidator
log = logging.getLogger(__name__)
......@@ -14,21 +16,81 @@ class AuthenticationBackend(BaseBackend):
def __init__(self, *args, **kwargs):
super(AuthenticationBackend, self).__init__(*args, **kwargs)
def is_valid_jwt(self, username=None, password=None):
is_valid = False
try:
jwt_wcapi = WOOCOMMERCE_API(
url=settings.WOO_URL,
consumer_key=settings.WOO_CONSUMER_KEY,
consumer_secret=settings.WOO_CONSUMER_SECRET,
wp_api=True,
version="jwt-auth/v1", # required for JWT Authentication
query_string_auth=settings.WOO_QUERY_STRING_AUTH,
)
jwt_response = jwt_wcapi.post("token", {"username": username, "password": password})
jwt_status = jwt_response.status_code
jwt_token = jwt_response.json().get("token", None)
jwt_code = jwt_response.json().get("code", None)
known_codes = ["[jwt_auth] incorrect_password", "[jwt_auth] invalid_username"]
if jwt_status == 200 and jwt_token is not None:
is_valid = True
elif jwt_status == 403 and jwt_code in known_codes:
# recognised authentication failure
is_valid = False
else:
# raise exception for an unrecognised failure
raise Exception("Unrecognised JWT response: %s" % (jwt_response.json(),))
except Exception as e:
logging.exception("JWT authentication failed with an unrecognised error: %s" % (e,))
finally:
return is_valid
def authenticate(self, request=None, username=None, password=None, **kwargs):
model = self.get_user_model()
username = model.normalize_username(username)
user_model = User
normalized_username = user_model.normalize_username(username)
# first, validate username (even if it exists, username must be valid)
if not settings.DEBUG_SKIP_VALIDATE_ON_AUTHENTICATION:
validator = UsernameValidator()
validator(username)
# second, attempt LDAP authentication (with early exit on success)
user = super(AuthenticationBackend, self).authenticate(request, normalized_username, password, **kwargs)
if user is not None:
return user
# third, attempt WooCommerce/JWT authentication
# (if successful, create and return LDAP user, otherwise return None)
if self.is_valid_jwt(normalized_username, password):
# TODO: also validate, so that existing but invalid usernames are not permitted?
# try to get a preexisting user object
# otherwise create a new one
return super(AuthenticationBackend, self).authenticate(request, username, password, **kwargs)
try:
user = user_model.objects.get(username=username)
except user_model.DoesNotExist:
user = user_model(username=username, email=None)
# update/set user details
user.email = user.get_identity()
user.set_password(password)