diff --git a/app.py b/app.py index e1c0d5dd..b1679c8c 100644 --- a/app.py +++ b/app.py @@ -2,17 +2,143 @@ from flask_mobility import Mobility from flask_socketio import SocketIO from flask_misaka import Misaka +from flask_sqlalchemy import SQLAlchemy +from flask_login import (LoginManager, current_user, login_required, + login_user, logout_user) + import os, sys +from os import environ, path +from logging import DEBUG, StreamHandler, basicConfig, getLogger base_dir = '.' if hasattr(sys, '_MEIPASS'): base_dir = os.path.join(sys._MEIPASS) + + + +#Variables +db = SQLAlchemy() +login_manager = LoginManager() md = Misaka() + +class Config(object): + """ + """ + #BASE DIR + basedir = path.abspath(path.dirname(__file__)) + + #Sqlalchemy + SECRET_KEY = environ.get('KEY_SECRET', 'love_it') + SQLALCHEMY_DATABASE_URI = 'sqlite:///' + path.join(basedir, 'site_database.db') + #sqlite:///:memory' + SQLALCHEMY_TRACK_MODIFICATIONS = False + + #theme + DEFAULT_THEME = None + +class ProductionConfig(Config): + """ + """ + #Developer + DEBUG = False + + #Security + SESSION_COOKIE_HTTPPONLY = True + REMEMBER_COOKIE_HTTPONLY = True + REMEMBER_COOKIE_DURATION = 3600 + + #PostgreSQL + #TODO: configure production DB extension + """ + SQLALCHEMY_DATABASE_URI = 'postgresql://{}:{}@{}:{}/{}'.format( + environ.get('LIGHT_DATABASE_USER', 'master'), + environ.get('LIGHT_DATABASE_PASSWORD', '123'), + environ.get('LIGHT_DATABASE_HOST', 'db'), + environ.get('LIGHT_DATABASE_PORT', 5433), + environ.get('LIGHT_DATABASE_NAME', 'master') + ) + + #OATH2 GOOGLE + GOOGLE_CLIENT_ID = environ.get('GOOGLE_CLIENT_ID', None) + GOOGLE_CLIENT_SECRET = environ.get('GOOGLE_CLIENT_SECRET', None) + GOOGLE_DISCOVERY_URL = ( + "https://accounts.google.com/.well-known/openid-configuration" + ) + """ + +class DebugConfig(Config): + + DEBUG = True + +config_dict = { + 'Debug': DebugConfig, + 'Production': ProductionConfig, + 'Config': Config +} + + +def register_extension(app): + """ + sd + """ + db.init_app(app) + login_manager.init_app(app) + +def configure_database(app): + """ + Configure database + """ + @app.before_first_request + def initalize_database(): + """ + This function will run once before the first request. + """ + db.create_all() + + @app.teardown_request + def shutdown_session(error=None): + """ + This function will run after a request, if exception occus or not. + """ + db.session.remove() + +def configure_loging(app): + """ + """ + try: + basicConfig(filename='logs.log', level = DEBUG) + logger = getLogger() + logger.addHandler(StreamHandler()) + except: + pass + +def configure_theme(app): + + """ + """ + #TODO: @app theme + pass + +config_mode_get = environ.get('CONFIG_MODE', 'Debug' ).strip().replace('\'','') #Debug, Config, Production + +try: + config_mode = config_dict[config_mode_get.capitalize()] + print(config_mode) +except KeyError: + exit('Error: Invalid CONFIG_MODE enviroment variable.') + + app = Flask(__name__, static_folder=os.path.join(base_dir, 'static'), template_folder=os.path.join(base_dir, 'templates')) +app.config.from_object(config_mode) + app.debug = True -socketio = SocketIO(app) -mobility = Mobility(app) md.init_app(app) - +register_extension(app) +configure_database(app) +configure_loging(app) +#TODO: +#configure_theme(app) +socketio = SocketIO(app) +mobility = Mobility(app) \ No newline at end of file diff --git a/forms.py b/forms.py new file mode 100644 index 00000000..b7730a83 --- /dev/null +++ b/forms.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Created: 07/12/2020 Robert Lambert +# Modified: + +from flask_wtf import FlaskForm +from wtforms import TextField, PasswordField +from wtforms.validators import InputRequired, Email, DataRequired + +## login and registration + +class LoginForm(FlaskForm): + username = TextField ('Username', id='username_login' , validators=[DataRequired()]) + password = PasswordField('Password', id='pwd_login' , validators=[DataRequired()]) + +class CreateAccountForm(FlaskForm): + username = TextField('Username' , id='username_create' , validators=[DataRequired()]) + email = TextField('Email' , id='email_create' , validators=[DataRequired(), Email()]) + password = PasswordField('Password' , id='pwd_create' , validators=[DataRequired()]) + diff --git a/main.py b/main.py index 9f41250f..3f0ba4e7 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ # main.py -from app import app, socketio +from app import app, socketio, db from gevent import monkey import webbrowser import socket @@ -15,8 +15,12 @@ import threading import json -from flask import Flask, jsonify, render_template, current_app, request, flash, Response, send_file, send_from_directory +from flask import Flask, jsonify, render_template, current_app, request, flash, Response, send_file, send_from_directory, redirect, url_for from flask_mobility.decorators import mobile_template +from flask_migrate import Migrate +from flask_login import (LoginManager, current_user, login_required, + login_user, logout_user) +from flask_sqlalchemy import SQLAlchemy from werkzeug import secure_filename from Background.UIProcessor import UIProcessor # do this after socketio is declared from Background.LogStreamer import LogStreamer # do this after socketio is declared @@ -25,6 +29,8 @@ from DataStructures.data import Data from Connection.nonVisibleWidgets import NonVisibleWidgets from WebPageProcessor.webPageProcessor import WebPageProcessor +from forms import LoginForm, CreateAccountForm +from models import User, User_activity, hash_pass, verify_pass from os import listdir from os.path import isfile, join @@ -97,14 +103,87 @@ def run_schedule(): @app.route("/") @mobile_template("{mobile/}") def index(template): + + if not current_user.is_authenticated: + return redirect( url_for('login')) + app.data.logger.resetIdler() macro1Title = (app.data.config.getValue("Maslow Settings", "macro1_title"))[:6] macro2Title = (app.data.config.getValue("Maslow Settings", "macro2_title"))[:6] if template == "mobile/": + return render_template("frontpage3d_mobile.html", modalStyle="modal-lg", macro1_title=macro1Title, macro2_title=macro2Title) else: return render_template("frontpage3d.html", modalStyle="mw-100 w-75", macro1_title=macro1Title, macro2_title=macro2Title) +@app.route("/login", methods=['GET', 'POST']) +def login(): + login_form = LoginForm(request.form) + + if 'login' in request.form: + + # read form data + username = request.form['username'] + password = request.form['password'] + + # Locate user + user = User.query.filter_by(username=username).first() + + # Check the password + if user and verify_pass( password, user.password): + + login_user(user) + user.activity += 1 + db.session.commit() + + return redirect(url_for('index')) + + # Something (user or pass) is not ok + return render_template( 'login/login.html', msg='Wrong user or password', form=login_form) + + + if not current_user.is_authenticated: + return render_template( 'login/login.html', + form=login_form) + + + return redirect(url_for('index')) + + +@app.route('/create_user', methods=['GET', 'POST']) +def create_user(): + login_form = LoginForm(request.form) + create_account_form = CreateAccountForm(request.form) + if 'register' in request.form: + + username = request.form['username'] + email = request.form['email' ] + + user = User.query.filter_by(username=username).first() + if user: + return render_template( 'login/register.html', msg='Username already registered', form=create_account_form) + + user = User.query.filter_by(email=email).first() + if user: + return render_template( 'login/register.html', msg='Email already registered', form=create_account_form) + + # else we can create the user + user = User(**request.form) + db.session.add(user) + db.session.commit() + + #return render_template( 'login/register.html', msg='User created please login', form=create_account_form ) + return redirect( url_for('login')) + + else: + return render_template( 'login/register.html', form=create_account_form) + +@app.route('/logout') +def logout(): + logout_user() + return redirect(url_for('login')) + + @app.route("/controls") @mobile_template("/controls/{mobile/}") def controls(template): @@ -118,6 +197,7 @@ def controls(template): @app.route("/text") @mobile_template("/text/{mobile/}") +@login_required def text(template): app.data.logger.resetIdler() macro1Title = (app.data.config.getValue("Maslow Settings", "macro1_title"))[:6] @@ -129,6 +209,7 @@ def text(template): @app.route("/logs") @mobile_template("/logs/{mobile/}") +@login_required def logs(template): print("here") app.data.logger.resetIdler() @@ -139,6 +220,7 @@ def logs(template): @app.route("/maslowSettings", methods=["POST"]) +@login_required def maslowSettings(): app.data.logger.resetIdler() if request.method == "POST": @@ -151,6 +233,7 @@ def maslowSettings(): @app.route("/advancedSettings", methods=["POST"]) +@login_required def advancedSettings(): app.data.logger.resetIdler() if request.method == "POST": @@ -163,6 +246,7 @@ def advancedSettings(): @app.route("/webControlSettings", methods=["POST"]) +@login_required def webControlSettings(): app.data.logger.resetIdler() if request.method == "POST": @@ -175,6 +259,7 @@ def webControlSettings(): @app.route("/cameraSettings", methods=["POST"]) +@login_required def cameraSettings(): app.data.logger.resetIdler() if request.method == "POST": @@ -186,6 +271,7 @@ def cameraSettings(): return resp @app.route("/gpioSettings", methods=["POST"]) +@login_required def gpioSettings(): app.data.logger.resetIdler() if request.method == "POST": @@ -197,6 +283,7 @@ def gpioSettings(): return resp @app.route("/uploadGCode", methods=["POST"]) +@login_required def uploadGCode(): app.data.logger.resetIdler() if request.method == "POST": @@ -234,6 +321,7 @@ def uploadGCode(): @app.route("/openGCode", methods=["POST"]) +@login_required def openGCode(): app.data.logger.resetIdler() if request.method == "POST": @@ -257,6 +345,7 @@ def openGCode(): return resp @app.route("/saveGCode", methods=["POST"]) +@login_required def saveGCode(): app.data.logger.resetIdler() if request.method == "POST": @@ -288,6 +377,7 @@ def saveGCode(): return resp @app.route("/openBoard", methods=["POST"]) +@login_required def openBoard(): app.data.logger.resetIdler() if request.method == "POST": @@ -311,6 +401,7 @@ def openBoard(): return resp @app.route("/saveBoard", methods=["POST"]) +@login_required def saveBoard(): app.data.logger.resetIdler() if request.method == "POST": @@ -335,6 +426,7 @@ def saveBoard(): @app.route("/importFile", methods=["POST"]) +@login_required def importFile(): app.data.logger.resetIdler() if request.method == "POST": @@ -355,6 +447,7 @@ def importFile(): return resp @app.route("/importFileWCJSON", methods=["POST"]) +@login_required def importFileJSON(): app.data.logger.resetIdler() if request.method == "POST": @@ -375,6 +468,7 @@ def importFileJSON(): return resp @app.route("/importRestoreWebControl", methods=["POST"]) +@login_required def importRestoreWebControl(): app.data.logger.resetIdler() if request.method == "POST": @@ -395,6 +489,7 @@ def importRestoreWebControl(): return resp @app.route("/sendGCode", methods=["POST"]) +@login_required def sendGcode(): app.data.logger.resetIdler() #print(request.form)#["gcodeInput"]) @@ -413,6 +508,7 @@ def sendGcode(): @app.route("/triangularCalibration", methods=["POST"]) +@login_required def triangularCalibration(): app.data.logger.resetIdler() if request.method == "POST": @@ -441,6 +537,7 @@ def triangularCalibration(): return resp @app.route("/holeyCalibration", methods=["POST"]) +@login_required def holeyCalibration(): app.data.logger.resetIdler() if request.method == "POST": @@ -470,6 +567,7 @@ def holeyCalibration(): return resp @app.route("/opticalCalibration", methods=["POST"]) +@login_required def opticalCalibration(): app.data.logger.resetIdler() if request.method == "POST": @@ -486,6 +584,7 @@ def opticalCalibration(): @app.route("/quickConfigure", methods=["POST"]) +@login_required def quickConfigure(): app.data.logger.resetIdler() if request.method == "POST": @@ -497,6 +596,7 @@ def quickConfigure(): return resp @app.route("/editGCode", methods=["POST"]) +@login_required def editGCode(): app.data.logger.resetIdler() #print(request.form["gcode"]) @@ -514,6 +614,7 @@ def editGCode(): return resp @app.route("/downloadDiagnostics", methods=["GET"]) +@login_required def downloadDiagnostics(): app.data.logger.resetIdler() if request.method == "GET": @@ -527,6 +628,7 @@ def downloadDiagnostics(): return resp @app.route("/backupWebControl", methods=["GET"]) +@login_required def backupWebControl(): app.data.logger.resetIdler() if request.method == "GET": @@ -541,6 +643,7 @@ def backupWebControl(): @app.route("/editBoard", methods=["POST"]) +@login_required def editBoard(): app.data.logger.resetIdler() if request.method == "POST": @@ -555,6 +658,7 @@ def editBoard(): return resp @app.route("/trimBoard", methods=["POST"]) +@login_required def trimBoard(): app.data.logger.resetIdler() if request.method == "POST": @@ -569,17 +673,20 @@ def trimBoard(): return resp @app.route("/assets/") +@login_required def sendDocs(path): print(path) return send_from_directory('docs/assets/', path) @socketio.on("checkInRequested", namespace="/WebMCP") +@login_required def checkInRequested(): socketio.emit("checkIn", namespace="/WebMCP") @socketio.on("connect", namespace="/WebMCP") +@login_required def watchdog_connect(): app.data.console_queue.put("watchdog connected") app.data.console_queue.put(request.sid) @@ -595,11 +702,13 @@ def watchdog_connect(): @socketio.on("my event", namespace="/MaslowCNC") +@login_required def my_event(msg): app.data.console_queue.put(msg["data"]) @socketio.on("modalClosed", namespace="/MaslowCNC") +@login_required def modalClosed(msg): app.data.logger.resetIdler() data = json.dumps({"title": msg["data"]}) @@ -608,6 +717,7 @@ def modalClosed(msg): @socketio.on("contentModalClosed", namespace="/MaslowCNC") +@login_required def contentModalClosed(msg): #Note, this shouldn't be called anymore #todo: cleanup @@ -629,6 +739,7 @@ def actionModalClosed(msg): ''' @socketio.on("alertModalClosed", namespace="/MaslowCNC") +@login_required def alertModalClosed(msg): app.data.logger.resetIdler() data = json.dumps({"title": msg["data"]}) @@ -637,6 +748,7 @@ def alertModalClosed(msg): @socketio.on("requestPage", namespace="/MaslowCNC") +@login_required def requestPage(msg): app.data.logger.resetIdler() app.data.console_queue.put(request.sid) @@ -653,6 +765,7 @@ def requestPage(msg): app.data.console_queue.put(e) @socketio.on("connect", namespace="/MaslowCNC") +@login_required def test_connect(): app.data.console_queue.put("connected") app.data.console_queue.put(request.sid) @@ -675,11 +788,13 @@ def test_connect(): app.data.ui_queue1.put("Action", "pyinstallUpdate", "on") @socketio.on("disconnect", namespace="/MaslowCNC") +@login_required def test_disconnect(): app.data.console_queue.put("Client disconnected") @socketio.on("action", namespace="/MaslowCNC") +@login_required def command(msg): app.data.logger.resetIdler() retval = app.data.actions.processAction(msg) @@ -692,6 +807,7 @@ def command(msg): os.system('sudo poweroff') @socketio.on("settingRequest", namespace="/MaslowCNC") +@login_required def settingRequest(msg): app.data.logger.resetIdler() # didn't move to actions.. this request is just to send it computed values.. keeping it here makes it faster than putting it through the UIProcessor @@ -701,6 +817,7 @@ def settingRequest(msg): socketio.emit("message", {"command": "requestedSetting", "data": data, "dataFormat": "json"}, namespace="/MaslowCNC",) @socketio.on("updateSetting", namespace="/MaslowCNC") +@login_required def updateSetting(msg): app.data.logger.resetIdler() if not app.data.actions.updateSetting(msg["data"]["setting"], msg["data"]["value"]): @@ -708,6 +825,7 @@ def updateSetting(msg): @socketio.on("checkForGCodeUpdate", namespace="/MaslowCNC") +@login_required def checkForGCodeUpdate(msg): app.data.logger.resetIdler() # this currently doesn't check for updated gcode, it just resends it.. @@ -716,6 +834,7 @@ def checkForGCodeUpdate(msg): app.data.ui_queue1.put("Action", "gcodeUpdate", "") @socketio.on("checkForBoardUpdate", namespace="/MaslowCNC") +@login_required def checkForBoardUpdate(msg): app.data.logger.resetIdler() # this currently doesn't check for updated board, it just resends it.. @@ -734,6 +853,7 @@ def log_connect(): socketio.emit("my response", {"data": "Connected", "count": 0}, namespace="/MaslowCNCLog") @socketio.on("disconnect", namespace="/MaslowCNCLogs") + def log_disconnect(): app.data.console_queue.put("Client disconnected") @@ -749,6 +869,7 @@ def isnumber(s): #def shutdown(): # print("Shutdown") +migrate = Migrate(app, db) if __name__ == "__main__": app.debug = False diff --git a/models.py b/models.py new file mode 100644 index 00000000..31e64170 --- /dev/null +++ b/models.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Created: 07/12/2020 Robert Lambert +# Modified: +# +#Model + +import hashlib, binascii, os + +from flask_login import UserMixin +from sqlalchemy import Binary, Column, Integer, String + +from app import db, login_manager + +class User(db.Model, UserMixin): + __tablename__ = 'User' + + id = Column(Integer, primary_key=True) + username = Column(String, unique=True) + email = Column(String, unique=True) + password = Column(Binary) + accountlevel = Column(Integer, default=1) + activity = Column(Integer, default=0) + + def __init__(self, **kwargs): + for property, value in kwargs.items(): + if hasattr(value, '__iter__') and not isinstance(value, str): + value = value[0] + if property == 'password': + value = hash_pass( value ) # we need bytes here (not plain str) + + setattr(self, property, value) + + def __repr__(self): + return str(self.username) + +class User_activity(db.Model): + __tablename__ = 'User_activity' + + id = Column(Integer, primary_key=True) + login_count = Column(Integer, default=0) + un = Column(String, unique=True) + + def __init__(self,**kwargs): + for property, value in kwargs.items(): + + setattr(self, property, value) + + +def hash_pass( password ): + """ + Hash + """ + salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') + pwdhash = hashlib.pbkdf2_hmac('sha512', password.encode('utf-8'), + salt, 100000) + pwdhash = binascii.hexlify(pwdhash) + return (salt + pwdhash) # return bytes + +def verify_pass(provided_password, stored_password): + """ + Verify password + """ + stored_password = stored_password.decode('ascii') + salt = stored_password[:64] + stored_password = stored_password[64:] + pwdhash = hashlib.pbkdf2_hmac('sha512', + provided_password.encode('utf-8'), + salt.encode('ascii'), + 100000) + pwdhash = binascii.hexlify(pwdhash).decode('ascii') + return pwdhash == stored_password + + + +@login_manager.user_loader +def user_loader(id): + return User.query.filter_by(id=id).first() + +@login_manager.request_loader +def request_loader(request): + username = request.form.get('username') + user = User.query.filter_by(username=username).first() + return user if user else None \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 776cff5c..72b6c533 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,26 +1,54 @@ +alembic==1.4.2 +certifi==2020.6.20 +cffi==1.14.0 +chardet==3.0.4 Click==7.0 +colorzero==1.1 +Deprecated==1.2.10 +distro==1.5.0 +dnspython==2.0.0 +email-validator==1.1.1 Flask==1.0.2 -Flask-Misaka +Flask-Login==0.5.0 +Flask-Migrate==2.5.3 +Flask-Misaka==1.0.0 Flask-Mobility==0.1.1 Flask-SocketIO==3.0.2 +Flask-SQLAlchemy==2.4.4 +Flask-WTF==0.14.3 gevent==1.3.7 +gpiozero==1.5.1 greenlet==0.4.15 +idna==2.10 +importlib-metadata==1.7.0 itsdangerous==0.24 -Jinja2>=2.10.1 -MarkupSafe==1.1 +Jinja2==2.11.2 +Mako==1.1.3 +Markdown==3.2.2 +MarkupSafe==1.1.0 +misaka==2.1.1 numpy==1.16.2 -scipy==1.3.1 opencv-python==3.4.3.18 +psutil==5.7.2 +pycparser==2.20 +PyGithub==1.51 +PyJWT==1.7.1 pyserial==3.4 +python-dateutil==2.8.1 +python-editor==1.0.4 python-engineio==2.3.2 +python-frontmatter==0.5.0 python-socketio==2.0.0 +PyYAML==5.3.1 +requests==2.24.0 schedule==0.5.0 +scipy==1.3.1 six==1.11.0 +SQLAlchemy==1.3.18 +urllib3==1.25.9 Werkzeug==0.15.3 -psutil -gpiozero -PyGithub -wget -distro -python-frontmatter -markdown \ No newline at end of file +wget==3.2 +wincertstore==0.2 +wrapt==1.12.1 +WTForms==2.3.1 +zipp==3.1.0 \ No newline at end of file diff --git a/templates/login/login.html b/templates/login/login.html new file mode 100644 index 00000000..e7ca4170 --- /dev/null +++ b/templates/login/login.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} + +{% block title %} +Login +{% endblock %} + + +{% block stylesheets %} +{% endblock stylesheets %} + +{% block content %} + +
+
+
+
+

+ Login +

+
+
+ +
+ {% if msg %} + {{ msg | safe }} + {% else %} + + Wellcome +
+ create your own user. +
+ {% endif %} +
+ +
+ +
+ + {{ form.hidden_tag() }} + +
+
+
+ + {{ form.username(class="form-control") }} +
+
+
+ +
+ +
+
+
+ + {{ form.password(class="form-control", type="password") }} +
+
+
+ +
+ + + +     + + Don't have an account? Create + + +
+ +
+
+
+
+
+ +{% endblock content %} + + +{% block javascripts %} +{% endblock javascripts %} diff --git a/templates/login/register.html b/templates/login/register.html new file mode 100644 index 00000000..8561aa20 --- /dev/null +++ b/templates/login/register.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} + +{% block title %} +Login +{% endblock %} + + +{% block stylesheets %} +{% endblock stylesheets %} + +{% block content %} + +
+
+
+
+

+ Create Account +

+
+
+ +
+ {% if msg %} + {{ msg | safe }} + {% else %} + Complete your credentials + {% endif %} +
+ +
+ +
+ + {{ form.hidden_tag() }} + +
+
+
+ + {{ form.username(class="form-control") }} +
+
+
+ +
+ +
+
+
+ + {{ form.email(class="form-control", type="email") }} +
+
+
+ +
+ +
+
+
+ + {{ form.password(class="form-control", type="password") }} +
+
+
+ +
+ + + +     + + Have an account? Login + + +
+ +
+
+
+
+
+ +{% endblock content %} + + +{% block javascripts %} +{% endblock javascripts %}