diff --git a/.gitignore b/.gitignore index c7771ae..ee8536d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ *.sqlite *.log venv/ +build.sh +CloudSession-Pkg.tar.gz ################# ## NetBeans @@ -16,3 +18,11 @@ nbactions.xml ################# .idea +build +copy-test +deploy-test +deploy2coppa +dbupdate.sh +/CloudSession-Templates.tar.gz +/copy-test +/deploy2coppa diff --git a/Failures.py b/Failures.py index d12ed9f..c97b29c 100644 --- a/Failures.py +++ b/Failures.py @@ -98,11 +98,17 @@ def screen_name_already_in_use(screen_name): }, 500 -def rate_exceeded(): +def rate_exceeded(time): + """ + Service requested to frequently. + + time - string representing the date and time the service will be available again + """ logging.debug('Failures: Rate exceeded') return { 'success': False, 'message': 'Insufficient bucket tokens', + 'data': time, 'code': 470 }, 500 diff --git a/app/AuthToken/controllers.py b/app/AuthToken/controllers.py index 5f0664a..0fb64fa 100644 --- a/app/AuthToken/controllers.py +++ b/app/AuthToken/controllers.py @@ -1,10 +1,9 @@ # Import the database object from the main app module -import json import logging import uuid import datetime - import Failures + from app import db from app.User import services as user_service diff --git a/app/AuthToken/models.py b/app/AuthToken/models.py index dd6e1d6..bea10aa 100644 --- a/app/AuthToken/models.py +++ b/app/AuthToken/models.py @@ -2,7 +2,6 @@ # We will define this inside /app/__init__.py in the next sections. from app import db - class AuthenticationToken(db.Model): id = db.Column(db.BigInteger, primary_key=True) id_user = db.Column(db.BigInteger, db.ForeignKey('user.id')) diff --git a/app/Authenticate/controllers.py b/app/Authenticate/controllers.py index 836517d..efc02a6 100644 --- a/app/Authenticate/controllers.py +++ b/app/Authenticate/controllers.py @@ -1,9 +1,7 @@ # Import the database object from the main app module import logging -import uuid -import datetime - import Failures + from app import db from app.User import services as user_services from app.RateLimiting import services as rate_limiting_services @@ -66,7 +64,11 @@ def post(self): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} api.add_resource(AuthenticateLocalUser, '/local') diff --git a/app/Email/services.py b/app/Email/services.py index 2fab71c..8ec3d4b 100644 --- a/app/Email/services.py +++ b/app/Email/services.py @@ -1,45 +1,133 @@ -from app import mail, app, db - +from app import mail, app from os.path import expanduser, isfile - -from flask.ext.mail import Message +from flask_mail import Message +from app.User.coppa import Coppa, SponsorType import pystache import logging +""" +TODO: System documentation goes here +""" + + def send_email_template_for_user(id_user, template, server, **kwargs): from app.User.services import get_user - logging.info("Sending email to user: %s (%s)", id_user, template) + # Get a copy of the user record + logging.info("Checking for a valid user record for user ID: %s", id_user) + user = get_user(id_user) + + if user is None: + logging.error("Cannot send email: Invalid user record") + return False + else: + logging.info("Valid record found for user: %s", user.id) + + logging.info("Sending email to user: %s using template: '%s'.", user.email, template) params = {} for key, value in kwargs.items(): + logging.debug("Logging parameter %s = %s", key, value) params[key] = value - user = get_user(id_user) - if user is None: - return False - + # The elements in the params array represent the data elements that are + # available to the email templates. params['screenname'] = user.screen_name + params['email'] = user.email + params['registrant-email'] = user.email + params['sponsoremail'] = user.parent_email + params['blocklyprop-host'] = app.config['CLOUD_SESSION_PROPERTIES']['response.host'] + + # Default the recipient email address + user_email = user.email + coppa = Coppa() + + # Send email to parent if user is under 13 years old + if template == 'confirm' and coppa.is_coppa_covered(user.birth_month, user.birth_year): + # Send email only to the sponsor address + user_email = user.parent_email + logging.info("COPPA account has a sponsor type of %s", user.parent_email_source) + + if user.parent_email_source == SponsorType.TEACHER: + # Teacher handles the account confirmation + send_email_template_to_address(user_email, 'confirm-teacher', server, user.locale, params) + elif user.parent_email_source == SponsorType.PARENT or\ + user.parent_email_source == SponsorType.GUARDIAN: + # Parent handles the account confirmation + send_email_template_to_address(user_email, 'confirm-parent', server, user.locale, params) + else: + logging.info("COPPA account %s has invalid sponsor type [%s]", user.id, user.parent_email_source) + + return + elif template == 'reset' and coppa.is_coppa_covered(user.birth_month, user.birth_year): + # Send email only to the sponsor address + logging.info("COPPA account has a sponsor type of %s", user.parent_email_source) + + # Send password reset to student and parent + send_email_template_to_address(user.email, 'reset-coppa', server, user.locale, params) + send_email_template_to_address(user.parent_email, 'reset-coppa', server, user.locale, params) + return + else: + # Registration not subject to COPPA regulations. + # + # Evaluate user wanting to use an alternate email address to register + # the account. + logging.info('Non-COPPA registration') + if user.parent_email_source == SponsorType.INDIVIDUAL and user.parent_email: + user_email = user.parent_email + logging.info('Individual sponsor email %s being used', user_email) + + if user.parent_email: + user_email = user.parent_email + logging.info('Sponsor email %s being used', user_email) - send_email_template_to_address(user.email, template, server, user.locale, params) + send_email_template_to_address(user_email, template, server, user.locale, params) + + return def send_email_template_to_address(recipient, template, server, locale, params=None, **kwargs): - # Read templates + logging.info("Preparing email template: %s for %s", template, recipient) params = params or {} + + # Add any supplied arguments to the parameter dictionary for key, value in kwargs.items(): params[key] = value + params['email'] = recipient params['locale'] = locale + # Create a URI-friendly version of the email addresses + params['email-uri'] = _convert_email_uri(params['email']) + logging.info("Email address %s converted to %s", + params['email'], + params['email-uri'] + ) + + params['registrant-email-uri'] = _convert_email_uri(params['registrant-email']) + logging.info("Registrant email address %s converted to %s", + params['registrant-email'], + params['registrant-email-uri'] + ) + + params['sponsor-email-uri'] = _convert_email_uri(params['sponsoremail']) + logging.info("Sponsor email address %s converted to %s", + params['sponsoremail'], + params['sponsor-email-uri'] + ) + + # Read templates (subject, plain, rich) = _read_templates(template, server, locale, params) + # Add error checking here to detect any issues with parsing the template. + logging.info("Sending email to %s", params['email']) send_email(recipient, subject, plain, rich) def send_email(recipient, subject, email_text, rich_email_text=None): + logging.info('Creating email message package') msg = Message( recipients=[recipient], subject=subject.rstrip(), @@ -47,28 +135,83 @@ def send_email(recipient, subject, email_text, rich_email_text=None): html=rich_email_text, sender=app.config['DEFAULT_MAIL_SENDER'] ) - mail.send(msg) + + # Attempt to send the email + try: + logging.info('Sending email message to server') + mail.send(msg) + except Exception as ex: + logging.error('Unable to send email') + logging.error('Error message: %s', ex.message) + return 1 + + logging.info('Email message was delivered to server') + return 0 def _read_templates(template, server, locale, params): + logging.info("Loading header text for template: %s", template) header = _read_template(template, server, locale, 'header', params) + + logging.info("Loading plain message text for template: %s", template) plain = _read_template(template, server, locale, 'plain', params) + + logging.info("Loading rich message text for template: %s", template) rich = _read_template(template, server, locale, 'rich', params, True) return header, plain, rich def _read_template(template, server, locale, part, params, none_if_missing=False): + """ + Render a mustache template. + + :param template: Base template name + :param server: Host server + :param locale: Language designator + :param part: Generic message type descriptor + :param params: Text string to replace tags embedded within the template + :param none_if_missing: Return 'none' if the requested template is not found + + :return: Upon success, return a Renderer object. Return none or a general + error message if the none_is_missing flag is false + """ template_file = expanduser("~/templates/%s/%s/%s/%s.mustache" % (locale, template, server, part)) + if isfile(template_file): logging.debug('Looking for template file: %s', template_file) + renderer = pystache.Renderer() - rendered = renderer.render_path(template_file, params) - #print(rendered) + + logging.debug('Rendering the template file') + try: + rendered = renderer.render_path(template_file, params) + except Exception as ex: + logging.error('Unable to render template file %s', template_file) + logging.error('Error message: %s', ex.message) + return 'Template format error.' + + logging.debug('Returning rendered template file.') return rendered else: - logging.error('Looking for template file: %s, but the file is missing', template_file) + logging.warn('Looking for template file: %s, but the file is missing', template_file) if none_if_missing: return None else: return 'Template missing' + + +def _convert_email_uri(email): + """ + Evaluate email address and replace any plus signs that may appear in the + portion of the address prior to the '@' with the literal '%2B'. + + Standard web servers will convert any plus ('+') symbol to a space (' ') + anywhere where they may appear in the URL. This will allow functions upstream + to create a URI that contains an email address that, when submitted to a + server, will not be replaced with a space character. + """ + if "+" in email: + return email.replace("+", "%2B") + else: + return email diff --git a/app/LocalUser/controllers.py b/app/LocalUser/controllers.py index ba0a45e..4757bbc 100644 --- a/app/LocalUser/controllers.py +++ b/app/LocalUser/controllers.py @@ -18,11 +18,26 @@ class DoConfirm(Resource): + """ + Confirm and activate a user account. + + Args: + None + + Returns: + A JSON document with the key 'success' set to True if the operation + is successful. Otherwise the key 'success' is set to False and the + field 'code' is set to the HTTP error code that represents a specific + reason when the account confirmation was rejected. + + Raises: + None + """ def post(self): # Get values - email = request.form.get('email') - token = request.form.get('token') + email = request.form.get('email') # User account email address + token = request.form.get('token') # Token assigned to account during account registration # Validate required fields validation = Validation() @@ -46,15 +61,19 @@ def post(self): confirm_token = ConfirmToken.query.filter_by(token=token).first() if confirm_token is None: - # Unkown token + # Unknown token return {'success': False, 'code': 510} if confirm_token.id_user != user.id: # Token is not for this user return {'success': False, 'code': 510} + # Set user account status to 'Confirmed' user.confirmed = True + # Delete the account confirmation token; it is no longer required db.session.delete(confirm_token) + + # Commit the user account changes db.session.commit() logging.info('LocalUser-controller: DoConfirm: success: %s', user.id) @@ -63,11 +82,22 @@ def post(self): class RequestConfirm(Resource): + """ + Send account confirmation request email to user + + Args: + param1: User account email address + + Returns: + JSON document detailing the success or failure of the request. + """ def get(self, email): - # Get values + # Get server URL server = request.headers.get('server') + logging.info("Requesting email confirmation for %s from server %s", email, server) + # Validate required fields validation = Validation() validation.add_required_field('email', email) @@ -95,11 +125,18 @@ def get(self, email): else: if code == 10: return Failures.rate_exceeded() - return { - 'success': False, - 'message': message, - 'code': 520 - } + elif code == 99: + return { + 'success': False, + 'message': message, + 'code': 540 + } + else: + return { + 'success': False, + 'message': message, + 'code': 520 + } class PasswordReset(Resource): diff --git a/app/RateLimiting/controllers.py b/app/RateLimiting/controllers.py index 2e7b972..989e183 100644 --- a/app/RateLimiting/controllers.py +++ b/app/RateLimiting/controllers.py @@ -44,12 +44,16 @@ def get(self, bucket_type, id_user): return Failures.email_not_confirmed() bucket_types = app.config['CLOUD_SESSION_PROPERTIES']['bucket.types'].split(',') + if bucket_type not in bucket_types: return Failures.unknown_bucket_type(bucket_type) - if not rate_limiting_services.consume_tokens(user.id, bucket_type, 1): + # Decrement a token count + result, next_time = rate_limiting_services.consume_tokens(user.id, bucket_type, 1) + + if not result: db.session.commit() - return Failures.rate_exceeded() + return Failures.rate_exceeded(next_time.strftime("%Y-%m-%d %H:%M:%S")) db.session.commit() @@ -82,6 +86,7 @@ def get(self, bucket_type, id_user, count): # Validate user exists, is validated and is not blocked user = user_services.get_user(id_user) + if user is None: return Failures.unknown_user_id(id_user) if user.blocked: @@ -90,12 +95,15 @@ def get(self, bucket_type, id_user, count): return Failures.email_not_confirmed() bucket_types = app.config['CLOUD_SESSION_PROPERTIES']['bucket.types'].split(',') + if bucket_type not in bucket_types: return Failures.unknown_bucket_type(bucket_type) - if not rate_limiting_services.consume_tokens(user.id, bucket_type, 1): + result, next_time = rate_limiting_services.consume_tokens(user.id, bucket_type, 1) + + if not result: db.session.commit() - return Failures.rate_exceeded() + return Failures.rate_exceeded(next_time.strftime("%Y-%m-%d %H:%M:%S")) db.session.commit() diff --git a/app/RateLimiting/services.py b/app/RateLimiting/services.py index 8507ced..ec75319 100644 --- a/app/RateLimiting/services.py +++ b/app/RateLimiting/services.py @@ -36,11 +36,11 @@ def consume_tokens(id_user, bucket_type, token_count): milliseconds_till_enough = (token_count - old_bucket_content) * bucket_stream_frequency date_when_enough = bucket.timestamp + datetime.timedelta(milliseconds=milliseconds_till_enough) # Log and return or throw error - return False + return False, date_when_enough bucket.content = bucket.content - token_count bucket.timestamp = datetime.datetime.now() - return True + return True, bucket.timestamp def has_sufficient_tokens(id_user, bucket_type, token_count): @@ -70,6 +70,6 @@ def has_sufficient_tokens(id_user, bucket_type, token_count): milliseconds_till_enough = (token_count - old_bucket_content) * bucket_stream_frequency date_when_enough = bucket.timestamp + datetime.timedelta(milliseconds=milliseconds_till_enough) # Log and return or throw error - return False + return False, date_when_enough return True diff --git a/app/User/controllers.py b/app/User/controllers.py index c4c8dbc..769baf9 100644 --- a/app/User/controllers.py +++ b/app/User/controllers.py @@ -10,12 +10,14 @@ from Validation import Validation from app.User import services as user_service -from models import User +# from models import User +# Define the endpoint prefix for user services user_app = Blueprint('user', __name__, url_prefix='/user') api = Api(user_app) +# Register a new user class Register(Resource): def post(self): @@ -27,6 +29,12 @@ def post(self): locale = request.form.get('locale') screen_name = request.form.get('screenname') + # COPPA support + birth_month = request.form.get('bdmonth') + birth_year = request.form.get('bdyear') + parent_email = request.form.get('parent-email') + parent_email_source = request.form.get('parent-email-source') + # Validate required fields validation = Validation() validation.add_required_field('server', server) @@ -35,6 +43,16 @@ def post(self): validation.add_required_field('password-confirm', password_confirm) validation.add_required_field('locale', locale) validation.add_required_field('screenname', screen_name) + + # COPPA support + validation.add_required_field('bdmonth', birth_month) + validation.add_required_field('bdyear', birth_year) + if parent_email: + validation.check_email('parent-email', parent_email) + if not validation.is_valid(): + return validation.get_validation_response() + + # Verify user email address validation.check_email('email', email) if not validation.is_valid(): return validation.get_validation_response() @@ -55,15 +73,23 @@ def post(self): if not user_service.check_password_complexity(password): return Failures.password_complexity() - id_user = user_service.create_local_user(server, email, password, locale, screen_name) - user_service.send_email_confirm(id_user, server) - - db.session.commit() + # Write user details to the database + id_user = user_service.create_local_user( + server, email, password, locale, screen_name, + birth_month, birth_year, parent_email, parent_email_source) - logging.info('User-controller: register success: %s', id_user) + # Send a confirmation request email to user or parent + (result, errno, mesg) = user_service.send_email_confirm(id_user, server) + if result: + # Commit the database record + db.session.commit() + logging.info('User-controller: register success: %s', id_user) - # Create user - return {'success': True, 'user': id_user} + # Create user + return {'success': True, 'user': id_user} + else: + logging.error("Unable to register user. Error %s: %s", errno, mesg) + return {'success': False, 'user': 0} class GetUserById(Resource): @@ -72,7 +98,7 @@ def get(self, id_user): # Parse numbers try: id_user = int(id_user) - except: + except ValueError: return Failures.not_a_number('idUser', id_user) # Validate user exists, is validated and is not blocked @@ -87,7 +113,11 @@ def get(self, id_user): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -106,7 +136,11 @@ def get(self, email): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -125,7 +159,11 @@ def get(self, screen_name): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -133,6 +171,7 @@ class DoInfoChange(Resource): def post(self, id_user): screen_name = request.form.get('screenname') + # Validate required fields validation = Validation() validation.add_required_field('id-user', id_user) @@ -143,7 +182,7 @@ def post(self, id_user): # Parse numbers try: id_user = int(id_user) - except: + except ValueError: return Failures.not_a_number('idUser', id_user) # Validate user exists, is validated and is not blocked @@ -166,7 +205,11 @@ def post(self, id_user): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -174,6 +217,7 @@ class DoLocaleChange(Resource): def post(self, id_user): locale = request.form.get('locale') + # Validate required fields validation = Validation() validation.add_required_field('id-user', id_user) @@ -184,7 +228,7 @@ def post(self, id_user): # Parse numbers try: id_user = int(id_user) - except: + except ValueError: return Failures.not_a_number('idUser', id_user) # Validate user exists, is validated and is not blocked @@ -202,13 +246,28 @@ def post(self, id_user): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} +# Supported endpoints +# Note: The url_prefix is '/user'. All user endpoints are in the form +# of host:port/user/_service_ +# +# Register a new user account api.add_resource(Register, '/register') + +# Retrieve details about an existing user account api.add_resource(GetUserById, '/id/') api.add_resource(GetUserByEmail, '/email/') api.add_resource(GetUserByScreenname, '/screenname/') + +# Update a user screen name api.add_resource(DoInfoChange, '/info/') + +# Update the local defined in the user account api.add_resource(DoLocaleChange, '/locale/') diff --git a/app/User/coppa.py b/app/User/coppa.py new file mode 100644 index 0000000..88fa09a --- /dev/null +++ b/app/User/coppa.py @@ -0,0 +1,38 @@ + +import datetime + +# Enumerate the sponsor types for COPPA eligible user accounts +class SponsorType: + INDIVIDUAL = 0 + PARENT = 1 + GUARDIAN = 2 + TEACHER = 3 + + def __init__(self): + pass + + +class Coppa: + def __init__(self): + pass + + # Return true if the date is less than 13 years + def is_coppa_covered(self, month, year): + # This is the number of months a typical thirteen years old has been on planet Earth. + cap = 156 + + # This is the actual number of months a typical user has been on the same planet. + user_age = (year * 12) + month + + # Current year and month + current_month = datetime.date.today().month + current_year = datetime.date.today().year + + # This represents the number of months since the inception of AD + # Unless you want to count that first year as part of BC. + current_cap = (current_year * 12) + current_month + + if current_cap - user_age > cap: + return False + else: + return True diff --git a/app/User/models.py b/app/User/models.py index 08310b0..9a7da45 100644 --- a/app/User/models.py +++ b/app/User/models.py @@ -2,6 +2,28 @@ class User(db.Model): + """ + User madel provides a direct mapping to the underlying + database. + + Version 1 deploys the following fields: + id (BigInteger) - Unique record identifier + email (String(250)) - User email address + password (String(100)) - User account password + salt (String(50)) - A hash used to encrypt password + auth_source (String(250)) - Identifier for system providing authentication + locale (String(50)) - User language + blocked (Boolean) - Flag to indicate account is disabled + confirmed (Boolean) - Flag to indicate account has been verified + screen_name (String(250)) - Unique user screen name + + Version 2 adds support for US COPPA compliance. The following fields were added: + birth_month (INTEGER) - User birth month + birth_year (INTEGER) - User birth year + parent_email (String(250)) - Sponsor email address + parent_email_source (INTEGER) - Classification of sponsor email address + + """ id = db.Column(db.BigInteger, primary_key=True) email = db.Column(db.String(250), unique=True) password = db.Column(db.String(100)) @@ -12,9 +34,16 @@ class User(db.Model): confirmed = db.Column(db.Boolean) screen_name = db.Column(db.String(250)) + # COPPA support + birth_month = db.Column(db.INTEGER, nullable=False) + birth_year = db.Column(db.INTEGER, nullable=False) + parent_email = db.Column(db.String(250)) + parent_email_source = db.Column(db.INTEGER) + def __init__(self): self.blocked = False self.confirmed = False + self.version = 2 def __repr__(self): return '' % self.email diff --git a/app/User/services.py b/app/User/services.py index 1a71da8..d72d0be 100644 --- a/app/User/services.py +++ b/app/User/services.py @@ -1,6 +1,6 @@ import hashlib import uuid - +import logging import datetime from app import db, app @@ -11,6 +11,10 @@ from models import User, ConfirmToken, ResetToken +def get_user(id_user): + return User.query.get(id_user) + + def get_password_hash(password): salt = str(uuid.uuid1()) password_hash = hashlib.sha256("%s:%s" % (password, salt)).hexdigest() @@ -23,10 +27,6 @@ def check_password(id_user, password): return user.password == password_hash -def get_user(id_user): - return User.query.get(id_user) - - def get_user_by_email(email): return User.query.filter_by(email=email).first() @@ -39,7 +39,10 @@ def check_password_complexity(password): return 8 <= len(password) < 200 -def create_local_user(server, email, password, locale, screen_name): +def create_local_user( + server, email, password, locale, screen_name, + birth_month, birth_year, parent_email, parent_email_source): + salt, password_hash = get_password_hash(password) # Save user @@ -51,6 +54,12 @@ def create_local_user(server, email, password, locale, screen_name): user.password = password_hash user.salt = salt + #COPPA support + user.birth_month = birth_month + user.birth_year = birth_year + user.parent_email = parent_email + user.parent_email_source = parent_email_source + db.session.add(user) db.session.flush() db.session.refresh(user) @@ -58,7 +67,10 @@ def create_local_user(server, email, password, locale, screen_name): return user.id -def create_oauth_user(server, email, source, locale, screen_name): +def create_oauth_user( + server, email, source, locale, screen_name, + birth_month, birth_year, parent_email, parent_email_source): + # Save user user = User() user.email = email @@ -68,6 +80,13 @@ def create_oauth_user(server, email, source, locale, screen_name): user.confirmed = True user.blocked = False + # COPPA support + user.birth_month = birth_month + user.birth_year = birth_year + user.parent_email = parent_email + user.parent_email_source = parent_email_source + + # Add the user record db.session.add(user) db.session.flush() db.session.refresh(user) @@ -76,16 +95,24 @@ def create_oauth_user(server, email, source, locale, screen_name): def send_email_confirm(id_user, server): + logging.info("Preparing new account confirmation email for user %s", id_user) + logging.info("Account request received from server: %s", server) + user = get_user(id_user) + if user is None: + logging.debug("Unknown user id: %s", id_user) return False, 1, 'User id not known' if user.confirmed: + logging.debug("User account %s has already been verified", id_user) return False, 2, 'Account already verified' if user.blocked: + logging.debug("User account %s has been blocked", id_user) return False, 3, 'Account Blocked' # check rate limiting if not rate_limiting_services.consume_tokens(id_user, 'email-confirm', 1): + logging.debug("Too many attempts to confirm account for user %s", id_user) return False, 10, 'Rate limiter exceeded' # Delete token if any exists @@ -103,7 +130,14 @@ def send_email_confirm(id_user, server): confirm_token.validity = datetime.datetime.now() + datetime.timedelta(hours=token_validity_time) db.session.add(confirm_token) - email_services.send_email_template_for_user(id_user, 'confirm', server, token=token) + try: + logging.info("Sending account confirmation email to user: %s ", id_user) + # Send an email to the user or user's responsible party to confirm the account request + email_services.send_email_template_for_user(id_user, 'confirm', server, token=token) + logging.info("Completed email send process.") + except Exception as ex: + logging.error("Error while sending email: %s", ex.message) + return False, 99, 'Unable to contact SMTP server' return True, 0, 'Success' @@ -137,3 +171,4 @@ def send_password_reset(id_user, server): email_services.send_email_template_for_user(id_user, 'reset', server, token=token) return True, 0, 'Success' + diff --git a/app/__init__.py b/app/__init__.py index 0ca2971..693d67d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,27 +1,44 @@ -# Import flask and template operators +""" +Cloud Session server application initialization + +""" # Import properties files utils import logging from ConfigParser import ConfigParser - -import os from FakeSecHead import FakeSecHead from os.path import expanduser, isfile # Import Flask -from flask import Flask, render_template +from flask import Flask -# Import SQLAlchemy -from flask.ext.sqlalchemy import SQLAlchemy +# Import SQLAlchemy database mapper +from flask_sqlalchemy import SQLAlchemy # Import Mail -from flask.ext.mail import Mail +from flask_mail import Mail # Define the WSGI application object from raven.contrib.flask import Sentry app = Flask(__name__) +# Application version (major,minor,patch-level) +version = "1.1.4" + +""" +Change Log + +1.1.4 Add code to convert plus signs located the the username portion + of an email address to a '%2B'when the email address is embedded + in a URL. + +1.1.3 Added documentation around the user account registration process. + +""" + +db = None + # Load basic configurations app.config.from_object('config') @@ -29,6 +46,9 @@ defaults = { 'database.url': 'mysql+mysqldb://cloudsession:cloudsession@localhost:3306/cloudsession', + 'request.host': 'http://localhost:8080/blockly', + 'response.host': 'localhost', + 'sentry-dsn': None, 'mail.host': 'localhost', @@ -51,30 +71,37 @@ 'bucket.password-reset.size': '2', 'bucket.password-reset.input': '1', - 'bucket.password-reset.freq': '1800000', + 'bucket.password-reset.freq': '600000', 'bucket.email-confirm.size': '2', 'bucket.email-confirm.input': '1', 'bucket.email-confirm.freq': '1800000' } +logging.basicConfig(level=logging.DEBUG) +logging.info('Log level set to %s', 'DEBUG') +logging.info('Starting Cloud Session Service v%s', version) + configfile = expanduser("~/cloudsession.properties") -print('Looking for config file: %s' % configfile) +logging.info('Looking for config file: %s', configfile) + if isfile(configfile): configs = ConfigParser(defaults) configs.readfp(FakeSecHead(open(configfile))) app_configs = {} + logging.debug('Configuration Key Settings') for (key, value) in configs.items('section'): app_configs[key] = value + logging.debug("Key:%s, Value:%s", key, value) + app.config['CLOUD_SESSION_PROPERTIES'] = app_configs + else: app.config['CLOUD_SESSION_PROPERTIES'] = defaults + logging.warn('WARNING: Using application defaults.') - -# -------------------------------------- Module initialization ------------------------------------------------- -logging.basicConfig(level=logging.DEBUG) - +# ---------- Init Sentry Module ---------- if app.config['CLOUD_SESSION_PROPERTIES']['sentry-dsn'] is not None: logging.info("Initializing Sentry") sentry = Sentry(app, @@ -85,22 +112,30 @@ else: logging.info("No Sentry configuration") - -app.config['SQLALCHEMY_DATABASE_URI'] = app.config['CLOUD_SESSION_PROPERTIES']['database.url'] - +# ---------- Init database package ---------- # Define the database object which is imported # by modules and controllers -logging.info("Initializing database connection") +# -------------------------------------------- +app.config['SQLALCHEMY_DATABASE_URI'] = app.config['CLOUD_SESSION_PROPERTIES']['database.url'] +logging.debug("Initializing database connection: %s", app.config['SQLALCHEMY_DATABASE_URI']) + db = SQLAlchemy(app) + app.config['MAIL_SERVER'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.host'] +logging.debug("Configuring SMTP properties for %s", app.config['MAIL_SERVER']) + +# Set the correct server port for encrypted vs unencrypted communications if app.config['CLOUD_SESSION_PROPERTIES']['mail.port'] is None: if app.config['CLOUD_SESSION_PROPERTIES']['mail.tls']: app.config['MAIL_PORT'] = 587 else: app.config['MAIL_PORT'] = 25 + + logging.info("Email server default port set to port %s", app.config['MAIL_PORT']) else: app.config['MAIL_PORT'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.port'] + app.config['MAIL_USE_TLS'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.tls'] app.config['MAIL_USE_SSL'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.ssl'] app.config['MAIL_DEBUG'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.debug'] @@ -108,18 +143,27 @@ app.config['MAIL_PASSWORD'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.password'] app.config['DEFAULT_MAIL_SENDER'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.from'] -#app.debug = True + logging.info("Initializing mail") +logging.info("SMTP port: %s", app.config['MAIL_PORT']) +logging.info("TLS: %s",app.config['MAIL_USE_TLS']) +logging.info("SSL: %s",app.config['MAIL_USE_SSL']) +logging.info("Sender: %s",app.config['DEFAULT_MAIL_SENDER']) + mail = Mail(app) -# -------------------------------------------- Services -------------------------------------------------------- +# ---------- Services ---------- logging.info("Initializing services") -from app.AuthToken.controllers import auth_token_app -from app.Authenticate.controllers import authenticate_app -from app.User.controllers import user_app -from app.LocalUser.controllers import local_user_app -from app.RateLimiting.controllers import rate_limiting_app -from app.OAuth.controllers import oauth_app + +# All of these imports need the database +if db is not None: + from app.Authenticate.controllers import authenticate_app + from app.AuthToken.controllers import auth_token_app + from app.User.controllers import user_app + from app.LocalUser.controllers import local_user_app + from app.RateLimiting.controllers import rate_limiting_app + from app.OAuth.controllers import oauth_app + app.register_blueprint(auth_token_app) app.register_blueprint(authenticate_app) diff --git a/cloudsession.properties.example b/cloudsession.properties.example new file mode 100644 index 0000000..0ff000d --- /dev/null +++ b/cloudsession.properties.example @@ -0,0 +1,73 @@ +# +# Sample Cloud Session configuration file +# + +# Database connection +database.url = jdbc:mysql://database.example.com:3306/cloudsession +database.username = database_user +database.password = database_user_password + +# SMTP server configuration +mail.host = smtp.example.com +mail.port = 25 +mail.from = no_reply@example.com +mail.authenticated = true +mail.user = authenticated_username +mail.password = authenticated_user_password +mail.ssl = false +mail.tls = true + +# The email notification system relies on a number of templates +# to produce the email messages sent to users. +email.template.path = /usr/share/tomcat7/templates + +# When a new account confirmation email is created, a security +# token is created and attached to the confirmation request. +# The token has a default lifespan of 12 hours. The default +# can be adjusted here. +# ---------------------------------------------------------- +confirm-token-validity-hours = 48 + + +# When a password reset request has been received, a security +# token is created and attached to an email sent to the user. +# The token has a default lifespan of 12 hours. The default +# value can be adjusted with this setting. +# ----------------------------------------------------------- +reset-token-validity-hours = 4 + +# Bucket types are objects that can be applied to the rate-limiting service built +# into the server. Bucket types are arbitrary units. There can be more than one +# bucket type defined. +# --------------------------------------------------------------------------------- +bucket.types = compile + +# Rate limiting +# Various buckets can be defined to limit the use of specific +# system resources and access to specific system features. +# Each bucket has three characteristics; size, input and +# frequency. These are defined as: +# +# size = Sets the number of time the access or feature can +# be used before the system stops listening to that +# user's requests for access or service. +# +# input = Sets the number of additional tokens are available +# at each interval as defined in 'freq'. +# +# freq = Set the interval, in milliseconds, that system will +# wait until adding tokens as set in 'input'. +# --------------------------------------------------------------- + +# Starting number of compiles +bucket.compile.size = 100 + +# Number of tokens added per interval +bucket.compile.input = 50 + +# Token add interval (ms) +bucket.compile.freq = 500000 + +# Enable detailed statistics on applicaiton operation +metrics.console.enable = false + diff --git a/cloudsession-schema.sql b/database/cloudsession-schema.sql similarity index 99% rename from cloudsession-schema.sql rename to database/cloudsession-schema.sql index f4f7c00..0addcfb 100644 --- a/cloudsession-schema.sql +++ b/database/cloudsession-schema.sql @@ -1,3 +1,7 @@ +/* + * Base Cloud Session database schema. + */ + -- MySQL dump 10.13 Distrib 5.6.24, for Win64 (x86_64) -- -- Host: localhost Database: cloudsession diff --git a/database/create_cloudsession_database.sql b/database/create_cloudsession_database.sql new file mode 100644 index 0000000..b404f2f --- /dev/null +++ b/database/create_cloudsession_database.sql @@ -0,0 +1,7 @@ +/* + * Create the cloudsession database + */ + +CREATE DATABASE IF NOT EXISTS `cloudsession` + CHARACTER SET = utf8 + COLLATE = utf8_general_ci; diff --git a/database/create_cloudsession_tables.sql b/database/create_cloudsession_tables.sql new file mode 100644 index 0000000..fb72912 --- /dev/null +++ b/database/create_cloudsession_tables.sql @@ -0,0 +1,103 @@ +/* + * Base Cloud Session database schema. + */ + +USE cloudsession; + + +-- +-- Table structure for table `user` +-- + +DROP TABLE IF EXISTS `user`; +CREATE TABLE `user` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `email` varchar(250) DEFAULT NULL, + `password` varchar(100) DEFAULT NULL, + `salt` varchar(50) DEFAULT NULL, + `auth_source` varchar(250) DEFAULT NULL, + `locale` varchar(50) DEFAULT NULL, + `blocked` tinyint(1) DEFAULT NULL, + `confirmed` tinyint(1) DEFAULT NULL, + `screen_name` varchar(250) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +) ENGINE=InnoDB + AUTO_INCREMENT=0 + DEFAULT CHARSET=utf8; + + +-- +-- Table structure for table `authentication_token` +-- +DROP TABLE IF EXISTS `authentication_token`; +CREATE TABLE `authentication_token` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `id_user` bigint(20) DEFAULT NULL, + `browser` varchar(200) DEFAULT NULL, + `server` varchar(1000) DEFAULT NULL, + `ip_address` varchar(200) DEFAULT NULL, + `validity` datetime DEFAULT NULL, + `token` varchar(200) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`), + KEY `id_user` (`id_user`), + CONSTRAINT `authentication_token_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id`) +) ENGINE=InnoDB + AUTO_INCREMENT=0 + DEFAULT CHARSET=utf8; + +-- +-- Table structure for table `bucket` +-- + +DROP TABLE IF EXISTS `bucket`; +CREATE TABLE `bucket` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `id_user` bigint(20) DEFAULT NULL, + `type` varchar(200) DEFAULT NULL, + `content` int(11) DEFAULT NULL, + `timestamp` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_type_unique` (`id_user`,`type`), + CONSTRAINT `bucket_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id`) +) ENGINE=InnoDB + AUTO_INCREMENT=0 + DEFAULT CHARSET=utf8; + +-- +-- Table structure for table `confirmtoken` +-- + +DROP TABLE IF EXISTS `confirm_token`; +CREATE TABLE `confirm_token` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `id_user` bigint(20) DEFAULT NULL, + `validity` datetime DEFAULT NULL, + `token` varchar(200) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `id_user` (`id_user`), + UNIQUE KEY `token` (`token`), + CONSTRAINT `confirm_token_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id`) +) ENGINE=InnoDB + AUTO_INCREMENT=0 + DEFAULT CHARSET=utf8; + + +-- +-- Table structure for table `resettoken` +-- + +DROP TABLE IF EXISTS `reset_token`; +CREATE TABLE `reset_token` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `id_user` bigint(20) DEFAULT NULL, + `validity` datetime DEFAULT NULL, + `token` varchar(200) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `id_user` (`id_user`), + UNIQUE KEY `token` (`token`), + CONSTRAINT `reset_token_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id`) +) ENGINE=InnoDB + AUTO_INCREMENT=0 + DEFAULT CHARSET=latin1; diff --git a/database/patches/0001-add-user-coach.sql b/database/patches/0001-add-user-coach.sql new file mode 100644 index 0000000..8c1b1a6 --- /dev/null +++ b/database/patches/0001-add-user-coach.sql @@ -0,0 +1,5 @@ +/* + * Add coach email address field to support email cc option. + */ +USE cloudsession; +ALTER TABLE cloudsession.user ADD COLUMN coach_email VARCHAR(250) AFTER screen_name; diff --git a/database/patches/0002-add-coppa-support.sql b/database/patches/0002-add-coppa-support.sql new file mode 100644 index 0000000..5e76365 --- /dev/null +++ b/database/patches/0002-add-coppa-support.sql @@ -0,0 +1,31 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ + +/** + * Add fields to user table to support US COPPA compliance + * + * Author: Jim Ewald + * Created: Apr 27, 2017A + * + * birth_month - is a range of [1 - 12] + * birth_year - is a range from [1930 - current year] + * parent_email - is used to register a child under the ae of 13. This is the + * email address of the parent, guardian or instructor that is + * creating the account on behalf of the child + * + * parent_email_source - is a integer designator to characterize the parent + * email adress noted above. Current options are: + * 0 - undefined + * 1 - child's parent + * 2 - child's guardian + * 3 - child's instructor or teacher + */ + +ALTER TABLE cloudsession.user ADD birth_month INT NOT NULL; +ALTER TABLE cloudsession.user ADD birth_year INT NOT NULL; +ALTER TABLE cloudsession.user ADD parent_email VARCHAR(250) NULL; +ALTER TABLE cloudsession.user ADD parent_email_source INT DEFAULT 0 NULL; +ALTER TABLE cloudsession.user DROP coach_email; diff --git a/database/patches/0003-add-user-timestamps.sql b/database/patches/0003-add-user-timestamps.sql new file mode 100644 index 0000000..9b5f23f --- /dev/null +++ b/database/patches/0003-add-user-timestamps.sql @@ -0,0 +1,6 @@ +/* + * Add datestamps to the user record to track when the record was created + * and when it was last modified. + */ +ALTER TABLE cloudsession.user ADD create_date DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL; +ALTER TABLE cloudsession.user ADD last_update DATETIME NULL ON UPDATE CURRENT_TIMESTAMP; diff --git a/database/patches/0004-update-cs-default-char-col.sql b/database/patches/0004-update-cs-default-char-col.sql new file mode 100644 index 0000000..9f22a5f --- /dev/null +++ b/database/patches/0004-update-cs-default-char-col.sql @@ -0,0 +1,82 @@ +/* +Script: 0004-update-cs-default-char-col.sql + +This script corrects an issue in the cloud session database +where the default character set and collation were set to +'latin1' and 'latin1_swedish_ci'. These settings should be +'utf8' and 'utf8_general_ci'. + +This script updates the character set and collation settings +on the cloudsession database, all cloudsession tables and +affected columns within each of these tables. + */ + +# Select the target database +USE cloudsession; + +# Set the database defaults +# This also sets the collation for individual table columns +ALTER DATABASE cloudsession CHARACTER SET utf8 COLLATE utf8_general_ci; + +# latin1_general_ci +# ALTER DATABASE cloudsession CHARACTER SET latin1 COLLATE latin1_general_ci; + +# Update the authentication_token table +SET foreign_key_checks = 0; +# Default table settings +ALTER TABLE cloudsession.authentication_token DEFAULT CHARACTER SET utf8; + +# Column settings +ALTER TABLE cloudsession.authentication_token MODIFY browser VARCHAR(200) CHARACTER SET utf8; +ALTER TABLE cloudsession.authentication_token MODIFY server VARCHAR(1000) CHARACTER SET utf8; +ALTER TABLE cloudsession.authentication_token MODIFY ip_address VARCHAR(200) CHARACTER SET utf8; +ALTER TABLE cloudsession.authentication_token MODIFY token VARCHAR(200) CHARACTER SET utf8; + +SET foreign_key_checks = 1; + + +# Update the bucket table +SET foreign_key_checks = 0; +ALTER TABLE cloudsession.bucket DEFAULT CHARACTER SET utf8; + +# Reset fields +ALTER TABLE cloudsession.bucket MODIFY type VARCHAR(200) CHARACTER SET utf8; + +SET foreign_key_checks = 1; + + +# Update the confirm_token table +SET foreign_key_checks = 0; +ALTER TABLE cloudsession.confirm_token DEFAULT CHARACTER SET utf8; + +# Reset fields +ALTER TABLE cloudsession.confirm_token MODIFY token VARCHAR(200) CHARACTER SET utf8; + +SET foreign_key_checks = 1; + + +# Update the reset_token table +SET foreign_key_checks = 0; +ALTER TABLE cloudsession.reset_token DEFAULT CHARACTER SET utf8; + +# Reset fields +ALTER TABLE cloudsession.reset_token MODIFY token VARCHAR(200) CHARACTER SET utf8; + +SET foreign_key_checks = 1; + + +# Update the user table +SET foreign_key_checks = 0; +ALTER TABLE cloudsession.user DEFAULT CHARACTER SET utf8; + +# Reset fields +ALTER TABLE cloudsession.user MODIFY email VARCHAR(250) CHARACTER SET utf8; +ALTER TABLE cloudsession.user MODIFY password VARCHAR(100) CHARACTER SET utf8; +ALTER TABLE cloudsession.user MODIFY salt VARCHAR(50) CHARACTER SET utf8; +ALTER TABLE cloudsession.user MODIFY auth_source VARCHAR(250) CHARACTER SET utf8; +ALTER TABLE cloudsession.user MODIFY locale VARCHAR(50) CHARACTER SET utf8; +ALTER TABLE cloudsession.user MODIFY screen_name VARCHAR(250) CHARACTER SET utf8; +ALTER TABLE cloudsession.user MODIFY parent_email VARCHAR(250) CHARACTER SET utf8; + +SET foreign_key_checks = 1; + diff --git a/database/tools/dumpdb.sh b/database/tools/dumpdb.sh new file mode 100755 index 0000000..cc2a24e --- /dev/null +++ b/database/tools/dumpdb.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# +# Dump the local cloudsession database to a .sql file +# + +echo 'Enter the MySQL user account password below' +mysqldump --single-transaction -u blocklydb -p cloudsession > cs-backup.sql + diff --git a/requirements.txt b/requirements.txt index 9fd50b6..302ce2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,22 @@ aniso8601==1.1.0 blinker==1.4 contextlib2==0.5.1 -Flask==0.10.1 +Flask==0.12.2 Flask-Mail==0.9.1 Flask-MySQLdb==0.2.0 -Flask-RESTful==0.3.5 -Flask-SQLAlchemy==2.1 +Flask-RESTful==0.3.6 +Flask-SQLAlchemy==2.3.2 itsdangerous==0.24 Jinja2==2.8 MarkupSafe==0.23 -mysqlclient==1.3.7 +mysqlclient==1.3.12 pystache==0.5.4 python-dateutil==2.5.2 pytz==2016.3 -raven==5.12.0 +raven==6.4.0 six==1.10.0 -SQLAlchemy==1.0.12 -validate-email==1.3 +# SQLAlchemy==1.0.12 +SQLAlchemy==1.2.0 Werkzeug==0.11.5 wheel==0.24.0 +validate_email==1.3 diff --git a/templates/en/confirm-parent/blocklyprop/header.mustache b/templates/en/confirm-parent/blocklyprop/header.mustache new file mode 100644 index 0000000..3425542 --- /dev/null +++ b/templates/en/confirm-parent/blocklyprop/header.mustache @@ -0,0 +1,2 @@ +{{! This is the email Subject line }} +New user registration confirmation request \ No newline at end of file diff --git a/templates/en/confirm-parent/blocklyprop/plain.mustache b/templates/en/confirm-parent/blocklyprop/plain.mustache new file mode 100644 index 0000000..48f6dff --- /dev/null +++ b/templates/en/confirm-parent/blocklyprop/plain.mustache @@ -0,0 +1,57 @@ +{{! + This is the text body of the email new account notification to + a parent or guardian + + Tags used in this template: + blocklyprop-host + locale + registrant-email + screenname + token +}} +Hello, + +Someone, perhaps your child, has requested a new account on the Parallax +BlocklyProp web site (http://blockly.parallax.com) under the screen name +{{screenname}}. When the account was created, your email address was +submitted as the parent or guardian of the registrant. If this is incorrect, +please accept our apologies. There is nothing more you need to do. The +request will automatically expire. + +Why are we sending this? In the US, the federal Children's Online Privacy +Protection Act, known as COPPA, requires that we communicate with a parent +or guardian if the person registering an BlocklyProp account is under the +age of 13. The Act allows you to decline the registration request. If you +choose this option, we will immediately remove the registration information +and the associated account. If you choose to confirm the the request and +activate the account, you may close the account at any time by clicking on +the link provided below. This action will close the account and remove any +projects that are associated with the account. + +A full copy of our Child Privacy Policy is available online at: +http://{{blocklyprop-host}}/blockly/child-privacy-policy + +BlocklyProp is a free, online programming tool designed for education. See +Getting Started with BlocklyProp for more information. + + +Confirm account registration: +Copy and paste into your browser +http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{registrant-email-uri}}&token={{token}} to confirm your email address. + +If the above link is unable to complete the registration, please go to +http://{{blocklyprop-host}}/blockly/confirm and enter your email address and the token: {{token}} + +If you do NOT want to complete your child's account registration, you need do +nothing more; this confirmation request will automatically expire in 7 days and +the account will not be created. + +If you do complete your child's account registration, you may close the account +in the future. Email a request for closure and your child's screen name to +blocklyadmin@parallax.com. We will confirm your request and then close the account +and remove any projects that are associated with the account. + +Regards, + +The Parallax team + diff --git a/templates/en/confirm-teacher/blocklyprop/header.mustache b/templates/en/confirm-teacher/blocklyprop/header.mustache new file mode 100644 index 0000000..7447513 --- /dev/null +++ b/templates/en/confirm-teacher/blocklyprop/header.mustache @@ -0,0 +1,2 @@ +{{! This is the email Subject line }} +Please confirm your student's email address for BlocklyProp diff --git a/templates/en/confirm-teacher/blocklyprop/plain.mustache b/templates/en/confirm-teacher/blocklyprop/plain.mustache new file mode 100644 index 0000000..fbe4f7c --- /dev/null +++ b/templates/en/confirm-teacher/blocklyprop/plain.mustache @@ -0,0 +1,22 @@ +{{! + This is the text body of the new account confirmation email directed to instructors +}} +Hello, + +A student has created an account on the Parallax BlocklyProp web site under the +screen name {{screenname}}. When the account was created, your email address was +registered as the classroom instructor. If this is incorrect, please accept our +apologies. There is nothing more you need to do. The request will automatically expire. + +If this is your student, please confirm the registration by copying the link below into +your browser or by navigating to the second link and confirming the student's email address. + +Copy and paste into your browser +http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{registrant-email-uri}}&token={{token}} to confirm your email address. + +If the above link is unable to complete your registration, please go to +http://{{blocklyprop-host}}/blockly/confirm and enter your student's email address ({{registrant-email-uri}}) and the token: {{token}} + +Regards, + +The Parallax team diff --git a/templates/en/confirm/blocklyprop/header.mustache b/templates/en/confirm/blocklyprop/header.mustache new file mode 100644 index 0000000..ed52201 --- /dev/null +++ b/templates/en/confirm/blocklyprop/header.mustache @@ -0,0 +1 @@ +{{! This is the email Subject line }}Please confirm your email address for BlocklyProp diff --git a/templates/en/confirm/blocklyprop/plain.mustache b/templates/en/confirm/blocklyprop/plain.mustache new file mode 100644 index 0000000..dde6f54 --- /dev/null +++ b/templates/en/confirm/blocklyprop/plain.mustache @@ -0,0 +1,9 @@ +{{! This is the text body of the email}} +Dear {{screenname}}, + +Please go to http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{registrant-email-uri}}&token={{token}} to confirm your email address. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/confirm, then enter your email address ({{email-uri}}), and the supplied token: {{token}} + + +The Parallax team diff --git a/templates/en/reset-coppa/blocklyprop/header.mustache b/templates/en/reset-coppa/blocklyprop/header.mustache new file mode 100644 index 0000000..630929b --- /dev/null +++ b/templates/en/reset-coppa/blocklyprop/header.mustache @@ -0,0 +1 @@ +Reset your BlocklyProp password diff --git a/templates/en/reset-coppa/blocklyprop/plain.mustache b/templates/en/reset-coppa/blocklyprop/plain.mustache new file mode 100644 index 0000000..946c325 --- /dev/null +++ b/templates/en/reset-coppa/blocklyprop/plain.mustache @@ -0,0 +1,15 @@ +{{! + This is the text body of the email that is sent to an account holder + to reset the password associated with the account. +}} +Hello, + +The BlocklyProp web site has received a request to reset the password for user '{{screenname}}'. + +If this request was made by you, please go to http://{{blocklyprop-host}}/blockly/reset?locale={{locale}}&email={{registrant-email-uri}}&token={{token}} to reset your password now. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/reset and use the token: {{token}} + +If you did not make this request, you may safely ignore this message. + +The Parallax team diff --git a/templates/en/reset/blocklyprop/header.mustache b/templates/en/reset/blocklyprop/header.mustache new file mode 100644 index 0000000..630929b --- /dev/null +++ b/templates/en/reset/blocklyprop/header.mustache @@ -0,0 +1 @@ +Reset your BlocklyProp password diff --git a/templates/en/reset/blocklyprop/plain.mustache b/templates/en/reset/blocklyprop/plain.mustache new file mode 100644 index 0000000..7c88411 --- /dev/null +++ b/templates/en/reset/blocklyprop/plain.mustache @@ -0,0 +1,11 @@ +Hello, + +The BlocklyProp web site has received a request to reset the password for user '{{screenname}}'. + +If this request was made by you, please go to http://{{blocklyprop-host}}/blockly/reset?locale={{locale}}&email={{registrant-email-uri}}&token={{token}} to reset your password now. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/reset and use the token: {{token}} + +If you did not make this request, you may safely ignore this message. + +The Parallax team diff --git a/templates/en_US/confirm-parent/blocklyprop/header.mustache b/templates/en_US/confirm-parent/blocklyprop/header.mustache new file mode 100644 index 0000000..3425542 --- /dev/null +++ b/templates/en_US/confirm-parent/blocklyprop/header.mustache @@ -0,0 +1,2 @@ +{{! This is the email Subject line }} +New user registration confirmation request \ No newline at end of file diff --git a/templates/en_US/confirm-parent/blocklyprop/plain.mustache b/templates/en_US/confirm-parent/blocklyprop/plain.mustache new file mode 100644 index 0000000..48f6dff --- /dev/null +++ b/templates/en_US/confirm-parent/blocklyprop/plain.mustache @@ -0,0 +1,57 @@ +{{! + This is the text body of the email new account notification to + a parent or guardian + + Tags used in this template: + blocklyprop-host + locale + registrant-email + screenname + token +}} +Hello, + +Someone, perhaps your child, has requested a new account on the Parallax +BlocklyProp web site (http://blockly.parallax.com) under the screen name +{{screenname}}. When the account was created, your email address was +submitted as the parent or guardian of the registrant. If this is incorrect, +please accept our apologies. There is nothing more you need to do. The +request will automatically expire. + +Why are we sending this? In the US, the federal Children's Online Privacy +Protection Act, known as COPPA, requires that we communicate with a parent +or guardian if the person registering an BlocklyProp account is under the +age of 13. The Act allows you to decline the registration request. If you +choose this option, we will immediately remove the registration information +and the associated account. If you choose to confirm the the request and +activate the account, you may close the account at any time by clicking on +the link provided below. This action will close the account and remove any +projects that are associated with the account. + +A full copy of our Child Privacy Policy is available online at: +http://{{blocklyprop-host}}/blockly/child-privacy-policy + +BlocklyProp is a free, online programming tool designed for education. See +Getting Started with BlocklyProp for more information. + + +Confirm account registration: +Copy and paste into your browser +http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{registrant-email-uri}}&token={{token}} to confirm your email address. + +If the above link is unable to complete the registration, please go to +http://{{blocklyprop-host}}/blockly/confirm and enter your email address and the token: {{token}} + +If you do NOT want to complete your child's account registration, you need do +nothing more; this confirmation request will automatically expire in 7 days and +the account will not be created. + +If you do complete your child's account registration, you may close the account +in the future. Email a request for closure and your child's screen name to +blocklyadmin@parallax.com. We will confirm your request and then close the account +and remove any projects that are associated with the account. + +Regards, + +The Parallax team + diff --git a/templates/en_US/confirm-teacher/blocklyprop/header.mustache b/templates/en_US/confirm-teacher/blocklyprop/header.mustache new file mode 100644 index 0000000..7447513 --- /dev/null +++ b/templates/en_US/confirm-teacher/blocklyprop/header.mustache @@ -0,0 +1,2 @@ +{{! This is the email Subject line }} +Please confirm your student's email address for BlocklyProp diff --git a/templates/en_US/confirm-teacher/blocklyprop/plain.mustache b/templates/en_US/confirm-teacher/blocklyprop/plain.mustache new file mode 100644 index 0000000..fbe4f7c --- /dev/null +++ b/templates/en_US/confirm-teacher/blocklyprop/plain.mustache @@ -0,0 +1,22 @@ +{{! + This is the text body of the new account confirmation email directed to instructors +}} +Hello, + +A student has created an account on the Parallax BlocklyProp web site under the +screen name {{screenname}}. When the account was created, your email address was +registered as the classroom instructor. If this is incorrect, please accept our +apologies. There is nothing more you need to do. The request will automatically expire. + +If this is your student, please confirm the registration by copying the link below into +your browser or by navigating to the second link and confirming the student's email address. + +Copy and paste into your browser +http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{registrant-email-uri}}&token={{token}} to confirm your email address. + +If the above link is unable to complete your registration, please go to +http://{{blocklyprop-host}}/blockly/confirm and enter your student's email address ({{registrant-email-uri}}) and the token: {{token}} + +Regards, + +The Parallax team diff --git a/templates/en_US/confirm/blocklyprop/header.mustache b/templates/en_US/confirm/blocklyprop/header.mustache new file mode 100644 index 0000000..8d1f20a --- /dev/null +++ b/templates/en_US/confirm/blocklyprop/header.mustache @@ -0,0 +1 @@ +Please confirm your email address for BlocklyProp diff --git a/templates/en_US/confirm/blocklyprop/plain.mustache b/templates/en_US/confirm/blocklyprop/plain.mustache new file mode 100644 index 0000000..dde6f54 --- /dev/null +++ b/templates/en_US/confirm/blocklyprop/plain.mustache @@ -0,0 +1,9 @@ +{{! This is the text body of the email}} +Dear {{screenname}}, + +Please go to http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{registrant-email-uri}}&token={{token}} to confirm your email address. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/confirm, then enter your email address ({{email-uri}}), and the supplied token: {{token}} + + +The Parallax team diff --git a/templates/en_US/reset-coppa/blocklyprop/header.mustache b/templates/en_US/reset-coppa/blocklyprop/header.mustache new file mode 100644 index 0000000..630929b --- /dev/null +++ b/templates/en_US/reset-coppa/blocklyprop/header.mustache @@ -0,0 +1 @@ +Reset your BlocklyProp password diff --git a/templates/en_US/reset-coppa/blocklyprop/plain.mustache b/templates/en_US/reset-coppa/blocklyprop/plain.mustache new file mode 100644 index 0000000..946c325 --- /dev/null +++ b/templates/en_US/reset-coppa/blocklyprop/plain.mustache @@ -0,0 +1,15 @@ +{{! + This is the text body of the email that is sent to an account holder + to reset the password associated with the account. +}} +Hello, + +The BlocklyProp web site has received a request to reset the password for user '{{screenname}}'. + +If this request was made by you, please go to http://{{blocklyprop-host}}/blockly/reset?locale={{locale}}&email={{registrant-email-uri}}&token={{token}} to reset your password now. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/reset and use the token: {{token}} + +If you did not make this request, you may safely ignore this message. + +The Parallax team diff --git a/templates/en_US/reset/blocklyprop/header.mustache b/templates/en_US/reset/blocklyprop/header.mustache new file mode 100644 index 0000000..630929b --- /dev/null +++ b/templates/en_US/reset/blocklyprop/header.mustache @@ -0,0 +1 @@ +Reset your BlocklyProp password diff --git a/templates/en_US/reset/blocklyprop/plain.mustache b/templates/en_US/reset/blocklyprop/plain.mustache new file mode 100644 index 0000000..7c88411 --- /dev/null +++ b/templates/en_US/reset/blocklyprop/plain.mustache @@ -0,0 +1,11 @@ +Hello, + +The BlocklyProp web site has received a request to reset the password for user '{{screenname}}'. + +If this request was made by you, please go to http://{{blocklyprop-host}}/blockly/reset?locale={{locale}}&email={{registrant-email-uri}}&token={{token}} to reset your password now. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/reset and use the token: {{token}} + +If you did not make this request, you may safely ignore this message. + +The Parallax team