完善采购审批流程、企业微信通知和采购专用章
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""Request-scoped transactions, authentication and account administration helpers."""
|
||||
import os
|
||||
import secrets
|
||||
import re
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (abort, current_app, flash, g, redirect, render_template,
|
||||
request, session, url_for)
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
from werkzeug.local import LocalProxy
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from models import User
|
||||
|
||||
|
||||
def next_page(value):
|
||||
"""Only preserve known local destinations from an approval notification."""
|
||||
if value in ('/', '/orders'):
|
||||
return value
|
||||
if isinstance(value, str) and re.fullmatch(r'/orders/\d+/(?:view|versions/\d+)', value):
|
||||
return value
|
||||
return '/'
|
||||
|
||||
|
||||
def login_redirect():
|
||||
return redirect(url_for('login', next=next_page(request.path)))
|
||||
|
||||
|
||||
def get_db():
|
||||
if 'db' not in g:
|
||||
g.db = current_app.extensions['po_session_factory']()
|
||||
return g.db
|
||||
|
||||
|
||||
db = LocalProxy(get_db)
|
||||
|
||||
|
||||
def current_user():
|
||||
uid = session.get('user_id')
|
||||
if not isinstance(uid, int):
|
||||
return None
|
||||
user = db.get(User, uid)
|
||||
if not user or not user.active or session.get('session_version') != user.session_version:
|
||||
session.clear()
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not current_user():
|
||||
return login_redirect()
|
||||
return f(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def role_required(*roles):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = current_user()
|
||||
if not user:
|
||||
return login_redirect()
|
||||
if user.role != 'admin' and user.role not in roles:
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def csrf_token():
|
||||
if 'csrf_token' not in session:
|
||||
session['csrf_token'] = secrets.token_hex(32)
|
||||
return session['csrf_token']
|
||||
|
||||
|
||||
def validate_password(password):
|
||||
if len(password) < 10 or len(password) > 128:
|
||||
raise ValueError('密码须为 10 至 128 个字符')
|
||||
if password in ('1234567890', 'admin123456', 'password123'):
|
||||
raise ValueError('请勿使用常见密码')
|
||||
|
||||
|
||||
def persistent_secret(path):
|
||||
path = Path(path)
|
||||
try:
|
||||
with path.open('x', encoding='ascii') as f:
|
||||
value = secrets.token_hex(32)
|
||||
f.write(value)
|
||||
except FileExistsError:
|
||||
value = path.read_text(encoding='ascii').strip()
|
||||
if len(value) < 32:
|
||||
raise RuntimeError('本地密钥文件不完整,请检查 %s' % path)
|
||||
return value
|
||||
|
||||
|
||||
def sign_in(user):
|
||||
session.clear()
|
||||
session.update(user_id=user.id, user_name=user.name, role=user.role,
|
||||
session_version=user.session_version)
|
||||
|
||||
|
||||
def init_security(app, factory, instance):
|
||||
app.extensions['po_session_factory'] = factory
|
||||
app.secret_key = os.environ.get('PO_SECRET_KEY') or persistent_secret(Path(instance) / 'session.key')
|
||||
if len(app.secret_key) < 32:
|
||||
raise RuntimeError('PO_SECRET_KEY 至少需要 32 个字符')
|
||||
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Lax',
|
||||
SESSION_COOKIE_SECURE=os.environ.get('PO_HTTPS') == '1')
|
||||
with factory() as bootstrap_db:
|
||||
if not bootstrap_db.query(User).count():
|
||||
persistent_secret(Path(instance) / 'setup.token')
|
||||
|
||||
@app.before_request
|
||||
def protect_request():
|
||||
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
|
||||
expected = session.get('csrf_token', '')
|
||||
supplied = request.form.get('csrf_token') or request.headers.get('X-CSRF-Token', '')
|
||||
if not expected or not secrets.compare_digest(expected.encode('utf-8'), supplied.encode('utf-8')):
|
||||
abort(400, description='页面已过期,请刷新后重新操作')
|
||||
# Serialize SQLite writes before reading state or allocating order numbers.
|
||||
# Every request owns its Session; GET requests remain independent readers.
|
||||
db.execute(text('BEGIN IMMEDIATE'))
|
||||
user = current_user()
|
||||
if user and user.must_change_password and request.endpoint not in ('change_password', 'logout', 'static'):
|
||||
return redirect(url_for('change_password', next=next_page(request.path)))
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(error=None):
|
||||
connection = g.pop('db', None)
|
||||
if connection is not None:
|
||||
connection.rollback()
|
||||
connection.close()
|
||||
|
||||
@app.errorhandler(ValueError)
|
||||
def invalid_input(error):
|
||||
db.rollback()
|
||||
return render_template('error.html', message=str(error)), 400
|
||||
|
||||
@app.errorhandler(SQLAlchemyError)
|
||||
def database_error(error):
|
||||
db.rollback()
|
||||
if isinstance(error, StaleDataError):
|
||||
message, status = '订单已被其他人修改,请刷新后核对最新内容再操作。', 409
|
||||
elif isinstance(error, IntegrityError):
|
||||
message, status = '数据已存在或被其他记录引用,请检查登录名、单号等内容后重试。', 409
|
||||
else:
|
||||
app.logger.exception('Database request failed')
|
||||
message, status = '暂时无法保存,请稍后重试;本次操作未生效。', 503
|
||||
return render_template('error.html', message=message), status
|
||||
|
||||
@app.context_processor
|
||||
def security_context():
|
||||
return {'csrf_token': csrf_token, 'request_key': lambda: secrets.token_hex(16),
|
||||
'current_user': current_user}
|
||||
|
||||
@app.route('/setup', methods=['GET', 'POST'])
|
||||
def setup():
|
||||
if db.query(User).count():
|
||||
return redirect(url_for('login'))
|
||||
if request.method == 'POST':
|
||||
expected = (Path(instance) / 'setup.token').read_text(encoding='ascii').strip()
|
||||
if not secrets.compare_digest(expected.encode('utf-8'), request.form.get('setup_token', '').encode('utf-8')):
|
||||
abort(403)
|
||||
username = request.form.get('username', '').strip()
|
||||
if not username or len(username) > 50:
|
||||
raise ValueError('请填写不超过 50 字的管理员登录名')
|
||||
password = request.form.get('password', '')
|
||||
validate_password(password)
|
||||
user = User(username=username, name=request.form.get('name', '').strip() or '系统管理员',
|
||||
password_hash=generate_password_hash(password), role='admin', active=True,
|
||||
session_version=1, must_change_password=False)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
sign_in(user)
|
||||
return redirect(url_for('index'))
|
||||
return render_template('setup.html')
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if not db.query(User).count():
|
||||
return redirect(url_for('setup'))
|
||||
if request.method == 'POST':
|
||||
user = db.query(User).filter_by(username=request.form.get('username', '').strip()).first()
|
||||
if user and user.active and check_password_hash(user.password_hash, request.form.get('password', '')):
|
||||
sign_in(user)
|
||||
destination = next_page(request.form.get('next'))
|
||||
return redirect(url_for('change_password', next=destination) if user.must_change_password else destination)
|
||||
flash('用户名或密码错误', 'error')
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/account/password', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
user = current_user()
|
||||
if request.method == 'POST':
|
||||
old = request.form.get('old_password', '')
|
||||
password = request.form.get('password', '')
|
||||
if not check_password_hash(user.password_hash, old):
|
||||
raise ValueError('原密码不正确')
|
||||
validate_password(password)
|
||||
if old == password or password != request.form.get('confirm_password'):
|
||||
raise ValueError('新密码须与原密码不同,且两次输入一致')
|
||||
user.password_hash = generate_password_hash(password)
|
||||
user.must_change_password = False
|
||||
user.session_version += 1
|
||||
db.commit()
|
||||
sign_in(user)
|
||||
flash('密码已更新,其他登录已失效', 'ok')
|
||||
return redirect(next_page(request.form.get('next')))
|
||||
return render_template('password.html', user=user)
|
||||
Reference in New Issue
Block a user