mirror of
https://github.com/simple-login/app.git
synced 2024-09-20 06:55:59 +08:00
bootstrap: db models, login, logout, dashboard pages
This commit is contained in:
commit
0b3dd21a06
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
.idea/
|
||||
*.pyc
|
||||
db.sqlite
|
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
1
app/auth/__init__.py
Normal file
1
app/auth/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .views import login, logout
|
3
app/auth/base.py
Normal file
3
app/auth/base.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from flask import Blueprint
|
||||
|
||||
auth_bp = Blueprint(name="auth", import_name=__name__, url_prefix="/auth")
|
0
app/auth/views/__init__.py
Normal file
0
app/auth/views/__init__.py
Normal file
36
app/auth/views/login.py
Normal file
36
app/auth/views/login.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
from flask import request, flash, render_template, redirect, url_for
|
||||
from flask_login import login_user
|
||||
from wtforms import Form, StringField, validators
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.log import LOG
|
||||
from app.models import User
|
||||
|
||||
|
||||
class LoginForm(Form):
|
||||
email = StringField("Email", validators=[validators.DataRequired()])
|
||||
password = StringField("Password", validators=[validators.DataRequired()])
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
form = LoginForm(request.form)
|
||||
|
||||
if request.method == "POST":
|
||||
if form.validate():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
|
||||
if not user:
|
||||
flash("No such email", "warning")
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
||||
if not user.check_password(form.password.data):
|
||||
flash("Wrong password", "warning")
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
||||
LOG.debug("log user %s in", user)
|
||||
login_user(user)
|
||||
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
return render_template("auth/login.html", form=form)
|
10
app/auth/views/logout.py
Normal file
10
app/auth/views/logout.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from flask import render_template
|
||||
from flask_login import logout_user
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
def logout():
|
||||
logout_user()
|
||||
return render_template("auth/logout.html")
|
1
app/dashboard/__init__.py
Normal file
1
app/dashboard/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .views import index
|
5
app/dashboard/base.py
Normal file
5
app/dashboard/base.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from flask import Blueprint
|
||||
|
||||
dashboard_bp = Blueprint(
|
||||
name="dashboard", import_name=__name__, url_prefix="/dashboard"
|
||||
)
|
0
app/dashboard/views/__init__.py
Normal file
0
app/dashboard/views/__init__.py
Normal file
10
app/dashboard/views/index.py
Normal file
10
app/dashboard/views/index.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from flask import render_template
|
||||
from flask_login import login_required
|
||||
|
||||
from app.dashboard.base import dashboard_bp
|
||||
|
||||
|
||||
@dashboard_bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
return render_template("dashboard/index.html")
|
34
app/extensions.py
Normal file
34
app/extensions.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from flask_login import LoginManager
|
||||
from flask_sqlalchemy import SQLAlchemy, Model
|
||||
|
||||
|
||||
class CRUDMixin(Model):
|
||||
"""Mixin that adds convenience methods for CRUD (create, read, update, delete) operations."""
|
||||
|
||||
@classmethod
|
||||
def create(cls, **kwargs):
|
||||
"""Create a new record and save it the database."""
|
||||
instance = cls(**kwargs)
|
||||
return instance.save()
|
||||
|
||||
def update(self, commit=True, **kwargs):
|
||||
"""Update specific fields of a record."""
|
||||
for attr, value in kwargs.items():
|
||||
setattr(self, attr, value)
|
||||
return commit and self.save() or self
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Save the record."""
|
||||
db.session.add(self)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return self
|
||||
|
||||
def delete(self, commit=True):
|
||||
"""Remove the record from the database."""
|
||||
db.session.delete(self)
|
||||
return commit and db.session.commit()
|
||||
|
||||
|
||||
db = SQLAlchemy(model_class=CRUDMixin)
|
||||
login_manager = LoginManager()
|
45
app/log.py
Normal file
45
app/log.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
_log_format = "%(asctime)s - %(name)s - %(levelname)s - %(process)d - %(module)s:%(lineno)d - %(funcName)s - %(message)s"
|
||||
_log_formatter = logging.Formatter(_log_format)
|
||||
|
||||
|
||||
def _get_console_handler(level=None):
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(_log_formatter)
|
||||
console_handler.formatter.converter = time.gmtime
|
||||
|
||||
if level:
|
||||
console_handler.setLevel(level)
|
||||
|
||||
return console_handler
|
||||
|
||||
|
||||
def get_logger(name):
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# leave the handlers level at NOTSET so the level checking is only handled by the logger
|
||||
logger.addHandler(_get_console_handler())
|
||||
|
||||
# no propagation to avoid unexpected behaviour
|
||||
logger.propagate = False
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
print(f">>> init logging <<<")
|
||||
|
||||
# ### config root logger ###
|
||||
# do not use the default (buggy) logger
|
||||
logging.root.handlers.clear()
|
||||
|
||||
# add handlers with the default level = "warn"
|
||||
# need to add level at handler level as there's no level check in root logger
|
||||
# all the libs logs having level >= WARN will be handled by these 2 handlers
|
||||
logging.root.addHandler(_get_console_handler(logging.WARN))
|
||||
|
||||
LOG = get_logger("yourkey")
|
51
app/models.py
Normal file
51
app/models.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
# <<< Models >>>
|
||||
from datetime import datetime
|
||||
|
||||
import bcrypt
|
||||
from flask_login import UserMixin
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class ModelMixin(object):
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=None, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class Client(db.Model, ModelMixin):
|
||||
client_id = db.Column(db.String(128), unique=True)
|
||||
client_secret = db.Column(db.String(128))
|
||||
redirect_uri = db.Column(db.String(1024))
|
||||
name = db.Column(db.String(128))
|
||||
|
||||
|
||||
class User(db.Model, ModelMixin, UserMixin):
|
||||
email = db.Column(db.String(128), unique=True)
|
||||
salt = db.Column(db.String(128), nullable=False)
|
||||
password = db.Column(db.String(128), nullable=False)
|
||||
name = db.Column(db.String(128))
|
||||
|
||||
def set_password(self, password):
|
||||
salt = bcrypt.gensalt()
|
||||
password_hash = bcrypt.hashpw(password.encode(), salt).decode()
|
||||
self.salt = salt.decode()
|
||||
self.password = password_hash
|
||||
|
||||
def check_password(self, password) -> bool:
|
||||
password_hash = bcrypt.hashpw(password.encode(), self.salt.encode())
|
||||
return self.password.encode() == password_hash
|
||||
|
||||
|
||||
class AuthorizationCode(db.Model, ModelMixin):
|
||||
code = db.Column(db.String(128), unique=True)
|
||||
client_id = db.Column(db.ForeignKey(Client.id))
|
||||
user_id = db.Column(db.ForeignKey(User.id))
|
||||
|
||||
|
||||
class OauthToken(db.Model, ModelMixin):
|
||||
access_token = db.Column(db.String(128), unique=True)
|
||||
client_id = db.Column(db.ForeignKey(Client.id))
|
||||
user_id = db.Column(db.ForeignKey(User.id))
|
||||
|
||||
user = db.relationship(User)
|
1
app/monitor/__init__.py
Normal file
1
app/monitor/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from . import views
|
3
app/monitor/base.py
Normal file
3
app/monitor/base.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from flask import Blueprint
|
||||
|
||||
monitor_bp = Blueprint(name="monitor", import_name=__name__, url_prefix="/")
|
16
app/monitor/views.py
Normal file
16
app/monitor/views.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
import subprocess
|
||||
|
||||
from app.monitor.base import monitor_bp
|
||||
|
||||
SHA1 = subprocess.getoutput("git rev-parse HEAD")
|
||||
|
||||
|
||||
@monitor_bp.route("/git")
|
||||
def git_sha1():
|
||||
return SHA1
|
||||
|
||||
|
||||
@monitor_bp.route("/exception")
|
||||
def test_exception():
|
||||
raise Exception("to make sure sentry works")
|
||||
return "never reach here"
|
8
app/utils.py
Normal file
8
app/utils.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
import random
|
||||
import string
|
||||
|
||||
|
||||
def random_string(length=10):
|
||||
"""Generate a random string of fixed length """
|
||||
letters = string.ascii_lowercase
|
||||
return "".join(random.choice(letters) for _ in range(length))
|
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
@ -0,0 +1,4 @@
|
|||
flask_sqlalchemy
|
||||
flask
|
||||
flask_login
|
||||
wtforms
|
76
server.py
Normal file
76
server.py
Normal file
|
@ -0,0 +1,76 @@
|
|||
import os
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.extensions import db, login_manager
|
||||
from app.log import LOG
|
||||
from app.models import Client, User
|
||||
from app.monitor.base import monitor_bp
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
app.secret_key = "secret"
|
||||
|
||||
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
||||
|
||||
init_extensions(app)
|
||||
register_blueprints(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def fake_data():
|
||||
# Remove db if exist
|
||||
if os.path.exists("db.sqlite"):
|
||||
os.remove("db.sqlite")
|
||||
|
||||
db.create_all()
|
||||
|
||||
# fake data
|
||||
client = Client(
|
||||
client_id="client-id",
|
||||
client_secret="client-secret",
|
||||
redirect_uri="http://localhost:7000/callback",
|
||||
name="Continental",
|
||||
)
|
||||
db.session.add(client)
|
||||
|
||||
user = User(id=1, email="john@wick.com", name="John Wick")
|
||||
user.set_password("password")
|
||||
db.session.add(user)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
user = User.query.get(user_id)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def register_blueprints(app: Flask):
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(monitor_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
|
||||
|
||||
def init_extensions(app: Flask):
|
||||
LOG.debug("init extensions")
|
||||
login_manager.init_app(app)
|
||||
db.init_app(app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
fake_data()
|
||||
|
||||
app.run(debug=True, threaded=False)
|
20
templates/_formhelpers.html
Normal file
20
templates/_formhelpers.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% macro render_field(field) %}
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-2 col-form-label">{{ field.label }}</label>
|
||||
<div class="col-sm-10">
|
||||
{{ field(**kwargs)|safe }}
|
||||
|
||||
<small class="form-text text-muted">
|
||||
{{ field.description }}
|
||||
</small>
|
||||
|
||||
{% if field.errors %}
|
||||
<ul class=errors>
|
||||
{% for error in field.errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
17
templates/auth/login.html
Normal file
17
templates/auth/login.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
{% from "_formhelpers.html" import render_field %}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}
|
||||
Login
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<form action="" method="post">
|
||||
{{ render_field(form.email) }}
|
||||
{{ render_field(form.password) }}
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
12
templates/auth/logout.html
Normal file
12
templates/auth/logout.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% from "_formhelpers.html" import render_field %}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}
|
||||
Logout
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
You are logged out. <br>
|
||||
<a href="{{ url_for('auth.login') }}">Login</a>
|
||||
{% endblock %}
|
65
templates/base.html
Normal file
65
templates/base.html
Normal file
|
@ -0,0 +1,65 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>
|
||||
{% block title %}{% endblock %} - Your Key
|
||||
</title>
|
||||
|
||||
<!-- Bootstrap -->
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
|
||||
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
|
||||
|
||||
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
|
||||
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css">
|
||||
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% block nav %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
<!-- Categories: success (green), info (blue), warning (yellow), danger (red) -->
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible" role="alert">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span
|
||||
aria-hidden="true">×</span></button>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
|
||||
integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
|
||||
integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
{% block script %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
30
templates/base_app.html
Normal file
30
templates/base_app.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
{# Base for all pages after user logs in #}
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block nav %}
|
||||
{% set navigation_bar = [
|
||||
(url_for("dashboard.index"), 'dashboard', 'Dashboard'),
|
||||
]-%}
|
||||
|
||||
|
||||
{% set active_page = active_page|default('index') -%}
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
{% for href, id, caption in navigation_bar %}
|
||||
<li{% if id == active_page %} class="nav-item active" {% else %} class="nav-item" {% endif %}>
|
||||
<a class="nav-link" href="{{ href|e }}">{{ caption|e }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<ul class="navbar-nav ml-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">
|
||||
{{ current_user.email }} (Logout)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</nav>
|
||||
{% endblock %}
|
13
templates/dashboard/index.html
Normal file
13
templates/dashboard/index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% from "_formhelpers.html" import render_field %}
|
||||
|
||||
{% extends 'base_app.html' %}
|
||||
|
||||
{% set active_page = "dashboard" %}
|
||||
|
||||
{% block title %}
|
||||
Dashboard
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
Dashboard
|
||||
{% endblock %}
|
Loading…
Reference in a new issue