From aa22102b5a916529283f992b59d14c314000ff5a Mon Sep 17 00:00:00 2001 From: LS Date: Fri, 28 Feb 2025 13:47:22 -0300 Subject: [PATCH 1/7] adicionando login - ainda precisa corrigir --- config.py | 1 + scripts/init_db.py | 27 +++++++++++++++++++++++++++ templates/login.html | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 config.py create mode 100644 scripts/init_db.py create mode 100644 templates/login.html diff --git a/config.py b/config.py new file mode 100644 index 0000000..a9766b0 --- /dev/null +++ b/config.py @@ -0,0 +1 @@ +SECRET_KEY = 'sua_chave_secreta_aqui' # Use uma chave segura em produção \ No newline at end of file diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..774bcf7 --- /dev/null +++ b/scripts/init_db.py @@ -0,0 +1,27 @@ +from functions.database import Role, Permissao, RolePermissao, Base, engine +from sqlalchemy.orm import Session + +def init_db(): + Base.metadata.create_all(engine) + + with Session(engine) as session: + # Criar roles + admin = Role(nome='Administrador', nivel=1) + coord = Role(nome='Coordenador', nivel=2) + milit = Role(nome='Militante', nivel=3) + + # Criar permissões + perm_admin = Permissao(nome='admin', descricao='Acesso total') + perm_militantes = Permissao(nome='ver_militantes', descricao='Ver militantes') + # ... outras permissões ... + + session.add_all([admin, coord, milit, perm_admin, perm_militantes]) + session.commit() + + # Associar permissões aos roles + session.add(RolePermissao(role=admin, permissao=perm_admin)) + session.add(RolePermissao(role=coord, permissao=perm_militantes)) + session.commit() + +if __name__ == '__main__': + init_db() \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..58bc333 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,40 @@ +{% extends 'base.html' %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+
+
+

Login

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+{% endblock %} \ No newline at end of file From 14c88bb1e41731e934b19bb6c6b9544cfb9b708f Mon Sep 17 00:00:00 2001 From: LS Date: Tue, 18 Mar 2025 17:31:59 -0300 Subject: [PATCH 2/7] Login ainda nao funciona mas esta quase --- app.py | 311 ++++++++++++++++++++++++--------- create_admin.py | 78 +++++++++ functions/database.py | 108 +++++++++++- requirements.txt | 32 ++-- templates/login.html | 57 +++--- templates/mostrar_qr_code.html | 41 +++++ templates/novo_usuario.html | 58 ++++++ 7 files changed, 560 insertions(+), 125 deletions(-) create mode 100644 create_admin.py create mode 100644 templates/mostrar_qr_code.html create mode 100644 templates/novo_usuario.html diff --git a/app.py b/app.py index e97959a..55decea 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -from flask import Flask, request, render_template, redirect, url_for, flash +from flask import Flask, request, render_template, redirect, url_for, flash, session from functions.database import ( Base, Militante, @@ -12,34 +12,90 @@ from functions.database import ( AssinaturaAnual, RelatorioCotasMensais, RelatorioVendasMateriais, - engine, + Usuario, + get_db_connection, ) -from sqlalchemy import create_engine, and_ +from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from datetime import datetime from flask_bootstrap import Bootstrap5 from routes.cota import cota_bp from functions.validations import validar_cpf - -Session = sessionmaker(bind=engine) -session = Session() +from functools import wraps +from pathlib import Path app = Flask(__name__) -app.secret_key = 'sua_chave_secreta_aqui' +app.secret_key = 'sua_chave_secreta_aqui' # Necessário para sessões do Flask bootstrap = Bootstrap5(app) +# Configuração da sessão do SQLAlchemy +db_session = get_db_connection() -def session_run(model): - session.add(model) - try: - session.commit() - except Exception as e: - print(e) - session.rollback() +# Decorator para verificar se o usuário está logado +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + flash('Por favor, faça login para acessar esta página.', 'warning') + return redirect(url_for('login', next=request.url)) + return f(*args, **kwargs) + return decorated_function +# Rota raiz - redireciona para login se não estiver autenticado +@app.route("/") +def index(): + if 'user_id' not in session: + return redirect(url_for('login')) + return redirect(url_for('home')) + +# Rota de login +@app.route("/login", methods=["GET", "POST"]) +def login(): + if 'user_id' in session: + return redirect(url_for('home')) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + otp = request.form.get("otp") + + user = db_session.query(Usuario).filter_by(username=username).first() + + if user and user.check_password(password) and user.verify_otp(otp): + session['user_id'] = user.id # Usando a sessão do Flask + session['is_admin'] = user.is_admin + next_page = request.args.get('next') + flash('Login realizado com sucesso!', 'success') + return redirect(next_page or url_for('home')) + else: + flash('Credenciais inválidas', 'danger') + + return render_template('login.html') + +# Rota de logout +@app.route("/logout") +def logout(): + session.clear() # Limpa a sessão do Flask + flash('Você foi desconectado com sucesso.', 'info') + return redirect(url_for('login')) + +# Rota home (protegida) +@app.route("/home") +@login_required +def home(): + """Página inicial do sistema""" + links = [] + for rule in app.url_map.iter_rules(): + if "GET" in rule.methods and has_no_empty_params(rule): + url = url_for(rule.endpoint, **(rule.defaults or {})) + endpoint_name = 'Início' if rule.endpoint == 'home' else rule.endpoint + links.append((url, endpoint_name)) + + return render_template('home.html', links=links) # Rota para criar um novo militante @app.route("/militantes/novo", methods=["GET", "POST"]) +@login_required def novo_militante(): if request.method == "POST": cpf = request.form["cpf"] @@ -58,22 +114,30 @@ def novo_militante(): filiado=bool(request.form.get("filiado", False)) ) - session_run(novo_militante) - flash('Militante cadastrado com sucesso!', 'success') - return redirect(url_for("listar_militantes")) + db_session.add(novo_militante) + try: + db_session.commit() + flash('Militante cadastrado com sucesso!', 'success') + return redirect(url_for("listar_militantes")) + except Exception as e: + print(e) + db_session.rollback() + flash('Erro ao cadastrar militante. Verifique se o CPF ou email já não estão cadastrados.', 'error') + return render_template("novo_militante.html", + dados_anteriores=request.form) return render_template("novo_militante.html") - # Rota para listar militantes @app.route("/militantes") +@login_required def listar_militantes(): - militantes = session.query(Militante).all() + militantes = db_session.query(Militante).all() return render_template("listar_militantes.html", militantes=militantes) - # Rota para criar uma nova cota mensal @app.route("/cotas/novo", methods=["GET", "POST"]) +@login_required def nova_cota(): if request.method == "POST": cotas_mensais = CotaMensal( @@ -83,21 +147,28 @@ def nova_cota(): data_alteracao=datetime.strptime(request.form["data_alteracao"], "%Y-%m-%d") ) - session_run(cotas_mensais) - return redirect(url_for("listar_cotas")) + db_session.add(cotas_mensais) + try: + db_session.commit() + return redirect(url_for("listar_cotas")) + except Exception as e: + print(e) + db_session.rollback() + flash('Erro ao cadastrar cota mensal. Verifique se os dados fornecidos são válidos.', 'error') + return render_template("nova_cota.html") return render_template("nova_cota.html") - # Rota para listar cotas mensais @app.route("/cotas") +@login_required def listar_cotas(): - cotas = session.query(CotaMensal).all() + cotas = db_session.query(CotaMensal).all() return render_template("listar_cotas.html", cotas=cotas) - # Rota para criar um novo pagamento @app.route("/pagamentos/novo", methods=["GET", "POST"]) +@login_required def novo_pagamento(): if request.method == "POST": pagamentos = Pagamento( @@ -107,21 +178,28 @@ def novo_pagamento(): data_pagamento=datetime.strptime(request.form["data_pagamento"], "%Y-%m-%d") ) - session_run(pagamentos) - return redirect(url_for("listar_pagamentos")) + db_session.add(pagamentos) + try: + db_session.commit() + return redirect(url_for("listar_pagamentos")) + except Exception as e: + print(e) + db_session.rollback() + flash('Erro ao cadastrar pagamento. Verifique se os dados fornecidos são válidos.', 'error') + return render_template("novo_pagamento.html") return render_template("novo_pagamento.html") - # Rota para listar pagamentos @app.route("/pagamentos") +@login_required def listar_pagamentos(): - pagamentos = session.query(Pagamento).all() + pagamentos = db_session.query(Pagamento).all() return render_template("listar_pagamentos.html", pagamentos=pagamentos) - # Rota para criar um novo material vendido @app.route("/materiais/novo", methods=["GET", "POST"]) +@login_required def novo_material(): if request.method == "POST": materiais_vendidos = MaterialVendido( @@ -132,21 +210,28 @@ def novo_material(): data_venda=datetime.strptime(request.form["data_venda"], "%Y-%m-%d"), ) - session_run(materiais_vendidos) - return redirect(url_for("listar_materiais")) + db_session.add(materiais_vendidos) + try: + db_session.commit() + return redirect(url_for("listar_materiais")) + except Exception as e: + print(e) + db_session.rollback() + flash('Erro ao cadastrar material vendido. Verifique se os dados fornecidos são válidos.', 'error') + return render_template("novo_material.html") return render_template("novo_material.html") - # Rota para listar materiais vendidos @app.route("/materiais") +@login_required def listar_materiais(): - materiais = session.query(MaterialVendido).all() + materiais = db_session.query(MaterialVendido).all() return render_template("listar_materiais.html", materiais=materiais) - # Rota para criar uma nova venda de jornais avulsos @app.route("/jornais/novo", methods=["GET", "POST"]) +@login_required def nova_venda_jornal(): if request.method == "POST": vendas_jornais_avulsos = VendaJornalAvulso( @@ -156,21 +241,28 @@ def nova_venda_jornal(): data_venda=datetime.strptime(request.form["data_venda"], "%Y-%m-%d"), ) - session_run(vendas_jornais_avulsos) - return redirect(url_for("listar_vendas_jornal")) + db_session.add(vendas_jornais_avulsos) + try: + db_session.commit() + return redirect(url_for("listar_vendas_jornal")) + except Exception as e: + print(e) + db_session.rollback() + flash('Erro ao cadastrar venda de jornal avulso. Verifique se os dados fornecidos são válidos.', 'error') + return render_template("nova_venda_jornal.html") return render_template("nova_venda_jornal.html") - # Rota para listar vendas de jornais avulsos @app.route("/jornais") +@login_required def listar_vendas_jornal(): - vendas = session.query(VendaJornalAvulso).all() + vendas = db_session.query(VendaJornalAvulso).all() return render_template("listar_vendas_jornal.html", vendas=vendas) - # Rota para criar uma nova assinatura anual @app.route("/assinaturas/novo", methods=["GET", "POST"]) +@login_required def nova_assinatura(): if request.method == "POST": assinaturas_anuais = AssinaturaAnual( @@ -182,21 +274,28 @@ def nova_assinatura(): data_fim=datetime.strptime(request.form["data_fim"], "%Y-%m-%d") ) - session_run(assinaturas_anuais) - return redirect(url_for("listar_assinaturas")) + db_session.add(assinaturas_anuais) + try: + db_session.commit() + return redirect(url_for("listar_assinaturas")) + except Exception as e: + print(e) + db_session.rollback() + flash('Erro ao cadastrar assinatura anual. Verifique se os dados fornecidos são válidos.', 'error') + return render_template("nova_assinatura.html") return render_template("nova_assinatura.html") - # Rota para listar assinaturas anuais @app.route("/assinaturas") +@login_required def listar_assinaturas(): - assinaturas = session.query(AssinaturaAnual).all() + assinaturas = db_session.query(AssinaturaAnual).all() return render_template("listar_assinaturas.html", assinaturas=assinaturas) - # Rota para criar um novo relatório de cotas mensais @app.route("/relatorios/cotas/novo", methods=["GET", "POST"]) +@login_required def novo_relatorio_cotas(): if request.method == "POST": relatorio_cotas_mensais = RelatorioCotasMensais( @@ -206,21 +305,28 @@ def novo_relatorio_cotas(): data_relatorio=datetime.strptime(request.form["data_relatorio"], "%Y-%m-%d") ) - session_run(relatorio_cotas_mensais) - return redirect(url_for("listar_relatorios_cotas")) + db_session.add(relatorio_cotas_mensais) + try: + db_session.commit() + return redirect(url_for("listar_relatorios_cotas")) + except Exception as e: + print(e) + db_session.rollback() + flash('Erro ao cadastrar relatório de cotas mensais. Verifique se os dados fornecidos são válidos.', 'error') + return render_template("novo_relatorio_cotas.html") return render_template("novo_relatorio_cotas.html") - # Rota para listar relatórios de cotas mensais @app.route("/relatorios/cotas") +@login_required def listar_relatorios_cotas(): - relatorios = session.query(RelatorioCotasMensais).all() + relatorios = db_session.query(RelatorioCotasMensais).all() return render_template("listar_relatorios_cotas.html", relatorios=relatorios) - # Rota para criar um novo relatório de vendas de materiais @app.route("/relatorios/vendas/novo", methods=["GET", "POST"]) +@login_required def novo_relatorio_vendas(): if request.method == "POST": relatorio_vendas_materiais = RelatorioVendasMateriais( @@ -230,42 +336,29 @@ def novo_relatorio_vendas(): data_relatorio=datetime.strptime(request.form["data_relatorio"], "%Y-%m-%d") ) - session_run(relatorio_vendas_materiais) - return redirect(url_for("listar_relatorios_vendas")) + db_session.add(relatorio_vendas_materiais) + try: + db_session.commit() + return redirect(url_for("listar_relatorios_vendas")) + except Exception as e: + print(e) + db_session.rollback() + flash('Erro ao cadastrar relatório de vendas de materiais. Verifique se os dados fornecidos são válidos.', 'error') + return render_template("novo_relatorio_vendas.html") return render_template("novo_relatorio_vendas.html") - # Rota para listar relatórios de vendas de materiais @app.route("/relatorios/vendas") +@login_required def listar_relatorios_vendas(): - relatorios = session.query(RelatorioVendasMateriais).all() + relatorios = db_session.query(RelatorioVendasMateriais).all() return render_template("listar_relatorios_vendas.html", relatorios=relatorios) - -@app.route("/") -def home(): - """Página inicial do sistema""" - links = [] - for rule in app.url_map.iter_rules(): - if "GET" in rule.methods and has_no_empty_params(rule): - url = url_for(rule.endpoint, **(rule.defaults or {})) - # Substituindo 'home' por 'início' no menu - endpoint_name = 'Início' if rule.endpoint == 'home' else rule.endpoint - links.append((url, endpoint_name)) - - return render_template('home.html', links=links) - - -def has_no_empty_params(rule): - defaults = rule.defaults if rule.defaults is not None else () - arguments = rule.arguments if rule.arguments is not None else () - return len(defaults) >= len(arguments) - - @app.route("/militantes/editar/", methods=["GET", "POST"]) +@login_required def editar_militante(id): - militante = session.query(Militante).get(id) + militante = db_session.query(Militante).get(id) if not militante: flash('Militante não encontrado.', 'error') return redirect(url_for('listar_militantes')) @@ -284,16 +377,77 @@ def editar_militante(id): militante.endereco = request.form["endereco"] militante.filiado = bool(request.form.get("filiado", False)) - session.commit() + db_session.commit() flash('Militante atualizado com sucesso!', 'success') return redirect(url_for('listar_militantes')) except Exception as e: - session.rollback() + db_session.rollback() flash('Erro ao atualizar militante. Verifique se o CPF ou email já não estão cadastrados.', 'error') return render_template("editar_militante.html", militante=militante) return render_template("editar_militante.html", militante=militante) +# Rota para criar novo usuário +@app.route("/usuarios/novo", methods=["GET", "POST"]) +@login_required +def novo_usuario(): + if not session.get('is_admin'): + flash('Acesso negado. Apenas administradores podem criar novos usuários.', 'danger') + return redirect(url_for('home')) + + if request.method == "POST": + username = request.form.get("username") + email = request.form.get("email") + password = request.form.get("password") + confirm_password = request.form.get("confirm_password") + is_admin = bool(request.form.get("is_admin")) + + # Validações + if password != confirm_password: + flash('As senhas não conferem.', 'danger') + return render_template('novo_usuario.html') + + # Verificar se usuário já existe + if db_session.query(Usuario).filter_by(username=username).first(): + flash('Nome de usuário já existe.', 'danger') + return render_template('novo_usuario.html') + + if db_session.query(Usuario).filter_by(email=email).first(): + flash('E-mail já cadastrado.', 'danger') + return render_template('novo_usuario.html') + + # Criar novo usuário + try: + novo_usuario = Usuario( + username=username, + password=password, + is_admin=is_admin + ) + novo_usuario.email = email + + db_session.add(novo_usuario) + db_session.commit() + + # Gerar QR code para configuração do OTP + qr_uri = novo_usuario.get_otp_uri() + flash('Usuário criado com sucesso!', 'success') + + # Modificação alternativa para salvar no diretório atual + qr_path = Path('admin_qr.png') + img = qrcode.QRCode(version=1, box_size=10, border=5) + img.add_data(qr_uri) + img.make(fit=True) + img.save(str(qr_path)) + + return render_template('mostrar_qr_code.html', qr_uri=qr_uri) + + except Exception as e: + db_session.rollback() + flash('Erro ao criar usuário. Por favor, tente novamente.', 'danger') + print(f"Erro: {e}") + return render_template('novo_usuario.html') + + return render_template('novo_usuario.html') def create_app(): app = Flask(__name__) @@ -304,7 +458,6 @@ def create_app(): # ... existing code ... return app - # Iniciar o servidor Flask if __name__ == "__main__": app.run(debug=True) diff --git a/create_admin.py b/create_admin.py new file mode 100644 index 0000000..5f34754 --- /dev/null +++ b/create_admin.py @@ -0,0 +1,78 @@ +from functions.database import Usuario, Base, engine, get_db_connection +import qrcode +import os +from pathlib import Path + +def create_admin_user(): + try: + # Criar as tabelas se não existirem + Base.metadata.create_all(engine) + + # Obter conexão com o banco + db_session = get_db_connection() + + # Verificar se já existe um admin + admin = db_session.query(Usuario).filter_by(username="admin").first() + + if not admin: + # Criar usuário admin + admin = Usuario( + username="admin", + password="admin123", + is_admin=True + ) + admin.email = "admin@example.com" + + db_session.add(admin) + db_session.commit() + + print("\n=== Usuário Admin Criado com Sucesso ===") + else: + print("\n=== Usuário Admin Encontrado ===") + + print(f"Username: admin") + print(f"Senha: admin123") + print(f"Segredo OTP: {admin.otp_secret}") + + # Gerar QR Code + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(admin.get_otp_uri()) + qr.make(fit=True) + + # Encontrar o diretório raiz do projeto (onde está o app.py) + current_dir = Path(__file__).resolve().parent + project_root = current_dir.parent # Volta um nível para a raiz do projeto + static_dir = project_root / 'static' + + # Criar diretório static se não existir + os.makedirs(static_dir, exist_ok=True) + + # Caminho completo para o arquivo QR code + qr_path = static_dir / 'admin_qr.png' + + # Salvar QR Code como imagem + img = qr.make_image(fill_color="black", back_color="white") + img.save(str(qr_path)) + + print(f"\nQR Code salvo em: {qr_path}") + print(f"Diretório atual: {os.getcwd()}") + print(f"O arquivo existe? {os.path.exists(qr_path)}") + + print("\nPasso a passo para configurar o OTP:") + print("1. Instale um aplicativo autenticador no seu celular") + print(" (Google Authenticator, Microsoft Authenticator, etc)") + print("2. Abra o aplicativo") + print("3. Selecione a opção para adicionar uma nova conta") + print("4. Escaneie o QR Code gerado") + print("\nOU, se preferir configuração manual:") + print(f"1. Use o segredo: {admin.otp_secret}") + print("2. Nome da conta: admin") + print("3. Tipo: Baseado em tempo (TOTP)") + + except Exception as e: + print(f"\nErro durante a execução: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + create_admin_user() \ No newline at end of file diff --git a/functions/database.py b/functions/database.py index 50bf650..c4d775d 100644 --- a/functions/database.py +++ b/functions/database.py @@ -1,6 +1,9 @@ from sqlalchemy import create_engine, Column, Integer, String, Boolean, Numeric, Date, ForeignKey from sqlalchemy.orm import relationship, sessionmaker from sqlalchemy.ext.declarative import declarative_base +from werkzeug.security import generate_password_hash, check_password_hash +import pyotp +import os Base = declarative_base() engine = create_engine('sqlite:///database.db', echo=True) @@ -130,6 +133,7 @@ class Setor(Base): relatorios_cotas = relationship("RelatorioCotasMensais", back_populates="setor") relatorios_vendas = relationship("RelatorioVendasMateriais", back_populates="setor") + usuarios = relationship("Usuario", back_populates="setor") class ComiteCentral(Base): __tablename__ = 'comites_centrais' @@ -164,4 +168,106 @@ class RelatorioVendasMateriais(Base): setor = relationship("Setor", back_populates="relatorios_vendas") comite = relationship("ComiteCentral", back_populates="relatorios_vendas") -Base.metadata.create_all(engine) \ No newline at end of file +class Usuario(Base): + __tablename__ = 'usuarios' + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + email = Column(String(100), unique=True, nullable=True) + otp_secret = Column(String(32), nullable=True) + role_id = Column(Integer, ForeignKey('roles.id'), nullable=True) + setor_id = Column(Integer, ForeignKey('setores.id'), nullable=True) + ativo = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + + role = relationship("Role", back_populates="usuarios") + setor = relationship("Setor", back_populates="usuarios") + + def __init__(self, username, password, is_admin=False): + self.username = username + self.set_password(password) + self.otp_secret = pyotp.random_base32() + self.is_admin = is_admin + self.ativo = True + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def verify_otp(self, otp_code): + totp = pyotp.TOTP(self.otp_secret) + return totp.verify(otp_code) + + def get_otp_uri(self): + totp = pyotp.TOTP(self.otp_secret) + return totp.provisioning_uri(self.username, issuer_name="Sistema de Gestão") + +class Role(Base): + __tablename__ = 'roles' + + id = Column(Integer, primary_key=True, autoincrement=True) + nome = Column(String(50), unique=True, nullable=False) + nivel = Column(Integer, nullable=False) # Nível hierárquico (1: admin, 2: coordenador, 3: militante) + + usuarios = relationship("Usuario", back_populates="role") + permissoes = relationship("RolePermissao", back_populates="role") + +class Permissao(Base): + __tablename__ = 'permissoes' + + id = Column(Integer, primary_key=True, autoincrement=True) + nome = Column(String(50), unique=True, nullable=False) + descricao = Column(String(255)) + + roles = relationship("RolePermissao", back_populates="permissao") + +class RolePermissao(Base): + __tablename__ = 'roles_permissoes' + + role_id = Column(Integer, ForeignKey('roles.id'), primary_key=True) + permissao_id = Column(Integer, ForeignKey('permissoes.id'), primary_key=True) + + role = relationship("Role", back_populates="permissoes") + permissao = relationship("Permissao", back_populates="roles") + +# Remover o banco de dados existente (se existir) +if os.path.exists('database.db'): + os.remove('database.db') + +# Criar todas as tabelas novamente +Base.metadata.create_all(engine) + +# Criar roles iniciais +def create_initial_data(): + session = get_db_connection() + try: + # Criar role de admin + admin_role = Role(nome="Administrador", nivel=1) + session.add(admin_role) + session.flush() # Para obter o ID da role + + # Criar usuário admin + admin = Usuario( + username="admin", + password="admin123", + is_admin=True + ) + admin.role_id = admin_role.id + session.add(admin) + + session.commit() + print(f"Segredo OTP do admin: {admin.otp_secret}") + print("Usuário admin criado com sucesso!") + + except Exception as e: + print(f"Erro ao criar dados iniciais: {e}") + session.rollback() + finally: + session.close() + +# Executar a criação dos dados iniciais +if __name__ == "__main__": + create_initial_data() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3b1bd8a..6d7217b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,14 @@ -black==24.10.0 -blinker==1.9.0 -click==8.1.7 -Flask==3.1.0 -greenlet==3.1.1 -importlib_metadata==8.5.0 -itsdangerous==2.2.0 -Jinja2==3.1.4 -MarkupSafe==3.0.2 -mypy-extensions==1.0.0 -mysql-connector-python==9.1.0 -packaging==24.2 -pathspec==0.12.1 -platformdirs==4.3.6 -SQLAlchemy==2.0.36 -tomli==2.2.1 -typing_extensions==4.12.2 -Werkzeug==3.1.3 -zipp==3.21.0 +Flask==3.0.2 +Flask-SQLAlchemy==3.1.1 +SQLAlchemy==2.0.27 +Werkzeug==3.0.1 +pyotp==2.9.0 +qrcode==7.4.2 +pillow==11.0.0 +python-dotenv==1.0.1 +flask-login==0.6.3 +flask-wtf==1.2.1 +email-validator==2.1.0.post1 Bootstrap-Flask==2.4.1 +flask-bootstrap5==0.1.dev1 + diff --git a/templates/login.html b/templates/login.html index 58bc333..8834c99 100644 --- a/templates/login.html +++ b/templates/login.html @@ -6,34 +6,39 @@
-

Login

- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} - -
-
- - +
+
+

Login

- -
- - +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + +
+ + +
+
+ + +
+
+ + + Digite o código de 6 dígitos do seu aplicativo autenticador +
+
+ +
+
- -
- - -
- - - +
diff --git a/templates/mostrar_qr_code.html b/templates/mostrar_qr_code.html new file mode 100644 index 0000000..7dadae4 --- /dev/null +++ b/templates/mostrar_qr_code.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} + +{% block title %}Configurar Autenticação em Dois Fatores{% endblock %} + +{% block content %} +
+
+
+
+
+

Configure a Autenticação em Dois Fatores

+
+
+

Siga os passos abaixo para configurar a autenticação em dois fatores:

+ +
    +
  1. Instale um aplicativo autenticador no seu celular (Google Authenticator, Microsoft Authenticator, etc)
  2. +
  3. Abra o aplicativo e escaneie o QR Code abaixo
  4. +
  5. O aplicativo irá gerar um código de 6 dígitos a cada 30 segundos
  6. +
  7. Use este código ao fazer login no sistema
  8. +
+ +
+ QR Code para OTP +
+ +
+ Importante: Guarde este QR Code em um lugar seguro. + Você precisará dele caso troque de celular ou reinstale o aplicativo autenticador. +
+ + +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/novo_usuario.html b/templates/novo_usuario.html new file mode 100644 index 0000000..f3ad9d3 --- /dev/null +++ b/templates/novo_usuario.html @@ -0,0 +1,58 @@ +{% extends 'base.html' %} + +{% block title %}Novo Usuário{% endblock %} + +{% block content %} +
+
+
+
+
+

Cadastro de Novo Usuário

+
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {% if session.get('is_admin') %} +
+ + +
+ {% endif %} + + + Voltar +
+
+
+
+
+
+{% endblock %} \ No newline at end of file From 986f90a9cdb37f9647a33f9292f97023ff31109f Mon Sep 17 00:00:00 2001 From: LS Date: Tue, 18 Mar 2025 17:36:42 -0300 Subject: [PATCH 3/7] continuando --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6d7217b..299e71c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ Flask==3.0.2 Flask-SQLAlchemy==3.1.1 -SQLAlchemy==2.0.27 +SQLAlchemy==2.0.39 Werkzeug==3.0.1 pyotp==2.9.0 qrcode==7.4.2 From 0f4056fbff17ae2ce49d64975d0ca52ae9b276df Mon Sep 17 00:00:00 2001 From: LS Date: Mon, 24 Mar 2025 14:50:42 -0300 Subject: [PATCH 4/7] Login funcionando --- app.py | 75 +++++++++++++++++++++++------- create_admin.py | 97 ++++++++++++++++---------------------- functions/database.py | 105 +++++++++++++++++++++++++++++------------- templates/home.html | 29 ++++++++---- templates/login.html | 14 ++++-- 5 files changed, 203 insertions(+), 117 deletions(-) diff --git a/app.py b/app.py index 55decea..afce09a 100644 --- a/app.py +++ b/app.py @@ -37,7 +37,7 @@ def login_required(f): def decorated_function(*args, **kwargs): if 'user_id' not in session: flash('Por favor, faça login para acessar esta página.', 'warning') - return redirect(url_for('login', next=request.url)) + return redirect(url_for('login')) return f(*args, **kwargs) return decorated_function @@ -59,16 +59,35 @@ def login(): password = request.form.get("password") otp = request.form.get("otp") + # Log dos dados recebidos (sem a senha) + print(f"Tentativa de login - Username: {username}, OTP fornecido: {'Sim' if otp else 'Não'}") + user = db_session.query(Usuario).filter_by(username=username).first() - if user and user.check_password(password) and user.verify_otp(otp): - session['user_id'] = user.id # Usando a sessão do Flask - session['is_admin'] = user.is_admin - next_page = request.args.get('next') - flash('Login realizado com sucesso!', 'success') - return redirect(next_page or url_for('home')) - else: - flash('Credenciais inválidas', 'danger') + if not user: + print(f"Erro: Usuário '{username}' não encontrado") + flash('Usuário não encontrado', 'danger') + return render_template('login.html') + + if not user.check_password(password): + print(f"Erro: Senha incorreta para o usuário '{username}'") + flash('Senha incorreta', 'danger') + return render_template('login.html') + + if not user.verify_otp(otp): + print(f"Erro: Código OTP inválido para o usuário '{username}'") + print(f"OTP fornecido: {otp}") + print(f"OTP secret do usuário: {user.otp_secret}") + flash('Código OTP inválido', 'danger') + return render_template('login.html') + + # Se chegou aqui, login bem sucedido + print(f"Login bem sucedido para o usuário '{username}'") + session['user_id'] = user.id + session['is_admin'] = user.is_admin + next_page = request.args.get('next') + flash('Login realizado com sucesso!', 'success') + return redirect(next_page or url_for('home')) return render_template('login.html') @@ -84,14 +103,36 @@ def logout(): @login_required def home(): """Página inicial do sistema""" - links = [] - for rule in app.url_map.iter_rules(): - if "GET" in rule.methods and has_no_empty_params(rule): - url = url_for(rule.endpoint, **(rule.defaults or {})) - endpoint_name = 'Início' if rule.endpoint == 'home' else rule.endpoint - links.append((url, endpoint_name)) - - return render_template('home.html', links=links) + try: + links = [] + # Filtrar apenas as rotas que queremos mostrar + allowed_endpoints = { + 'listar_militantes': 'Militantes', + 'listar_cotas': 'Cotas', + 'listar_pagamentos': 'Pagamentos', + 'listar_materiais': 'Materiais', + 'listar_vendas_jornal': 'Vendas de Jornal', + 'listar_assinaturas': 'Assinaturas' + } + + for rule in app.url_map.iter_rules(): + if (rule.endpoint in allowed_endpoints and + "GET" in rule.methods and + len(rule.arguments) == 0): # Apenas rotas sem parâmetros + url = url_for(rule.endpoint) + nome = allowed_endpoints[rule.endpoint] + links.append((url, nome)) + + # Ordenar links pelo nome + links.sort(key=lambda x: x[1]) + + return render_template('home.html', links=links) + except Exception as e: + print(f"Erro na página inicial: {e}") + import traceback + traceback.print_exc() + flash('Erro ao carregar a página inicial', 'error') + return render_template('home.html', links=[]) # Rota para criar um novo militante @app.route("/militantes/novo", methods=["GET", "POST"]) diff --git a/create_admin.py b/create_admin.py index 5f34754..ee162d4 100644 --- a/create_admin.py +++ b/create_admin.py @@ -1,78 +1,61 @@ -from functions.database import Usuario, Base, engine, get_db_connection +from functions.database import init_database, Usuario, get_db_connection import qrcode import os from pathlib import Path def create_admin_user(): try: - # Criar as tabelas se não existirem - Base.metadata.create_all(engine) + # Inicializar o banco de dados + init_database() - # Obter conexão com o banco + # Obter a sessão db_session = get_db_connection() - # Verificar se já existe um admin + # Verificar se o admin foi criado admin = db_session.query(Usuario).filter_by(username="admin").first() - if not admin: - # Criar usuário admin - admin = Usuario( - username="admin", - password="admin123", - is_admin=True - ) - admin.email = "admin@example.com" + if admin: + print("\n=== Detalhes do Usuário Admin ===") + print(f"Username: admin") + print(f"Email: {admin.email}") + print(f"OTP Secret: {admin.otp_secret}") - db_session.add(admin) - db_session.commit() + # Configurar diretório para o QR Code + home = Path.home() + qr_dir = home / '.local' / 'share' / 'controles' / 'qrcodes' + qr_dir.mkdir(parents=True, exist_ok=True) - print("\n=== Usuário Admin Criado com Sucesso ===") + # Gerar QR Code + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(admin.get_otp_uri()) + qr.make(fit=True) + + # Salvar QR Code + qr_path = qr_dir / 'admin_qr.png' + img = qr.make_image(fill_color="black", back_color="white") + img.save(str(qr_path)) + + print(f"\nQR Code salvo em: {qr_path}") + print("\nPasso a passo para configurar o OTP:") + print("1. Instale um aplicativo autenticador no seu celular") + print(" (Google Authenticator, Microsoft Authenticator, etc)") + print("2. Abra o aplicativo") + print("3. Selecione a opção para adicionar uma nova conta") + print("4. Escaneie o QR Code gerado") + print("\nOU, se preferir configuração manual:") + print(f"1. Use o segredo: {admin.otp_secret}") + print("2. Nome da conta: admin") + print("3. Tipo: Baseado em tempo (TOTP)") else: - print("\n=== Usuário Admin Encontrado ===") - - print(f"Username: admin") - print(f"Senha: admin123") - print(f"Segredo OTP: {admin.otp_secret}") - - # Gerar QR Code - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(admin.get_otp_uri()) - qr.make(fit=True) - - # Encontrar o diretório raiz do projeto (onde está o app.py) - current_dir = Path(__file__).resolve().parent - project_root = current_dir.parent # Volta um nível para a raiz do projeto - static_dir = project_root / 'static' - - # Criar diretório static se não existir - os.makedirs(static_dir, exist_ok=True) - - # Caminho completo para o arquivo QR code - qr_path = static_dir / 'admin_qr.png' - - # Salvar QR Code como imagem - img = qr.make_image(fill_color="black", back_color="white") - img.save(str(qr_path)) - - print(f"\nQR Code salvo em: {qr_path}") - print(f"Diretório atual: {os.getcwd()}") - print(f"O arquivo existe? {os.path.exists(qr_path)}") - - print("\nPasso a passo para configurar o OTP:") - print("1. Instale um aplicativo autenticador no seu celular") - print(" (Google Authenticator, Microsoft Authenticator, etc)") - print("2. Abra o aplicativo") - print("3. Selecione a opção para adicionar uma nova conta") - print("4. Escaneie o QR Code gerado") - print("\nOU, se preferir configuração manual:") - print(f"1. Use o segredo: {admin.otp_secret}") - print("2. Nome da conta: admin") - print("3. Tipo: Baseado em tempo (TOTP)") - + print("ERRO: Falha ao criar usuário admin!") + except Exception as e: print(f"\nErro durante a execução: {e}") import traceback traceback.print_exc() + finally: + if 'db_session' in locals(): + db_session.close() if __name__ == "__main__": create_admin_user() \ No newline at end of file diff --git a/functions/database.py b/functions/database.py index c4d775d..56504d0 100644 --- a/functions/database.py +++ b/functions/database.py @@ -4,16 +4,32 @@ from sqlalchemy.ext.declarative import declarative_base from werkzeug.security import generate_password_hash, check_password_hash import pyotp import os +from pathlib import Path +from sqlalchemy.pool import NullPool + +# Configurar caminho do banco de dados +db_dir = Path.home() / '.local' / 'share' / 'controles' +db_dir.mkdir(parents=True, exist_ok=True) +db_path = db_dir / 'database.db' + +# Configurar engine com NullPool +engine = create_engine( + f'sqlite:///{db_path}', + echo=True, + poolclass=NullPool # Usar NullPool ao invés do pool padrão +) Base = declarative_base() -engine = create_engine('sqlite:///database.db', echo=True) SessionLocal = sessionmaker(bind=engine) def get_db_connection(): """ Retorna uma nova sessão do banco de dados """ - return SessionLocal() + try: + return SessionLocal() + finally: + engine.dispose() def execute_query(query, params=None): """ @@ -195,11 +211,17 @@ class Usuario(Base): self.password_hash = generate_password_hash(password) def check_password(self, password): - return check_password_hash(self.password_hash, password) + result = check_password_hash(self.password_hash, password) + print(f"Verificação de senha para {self.username}: {'sucesso' if result else 'falha'}") + return result def verify_otp(self, otp_code): totp = pyotp.TOTP(self.otp_secret) - return totp.verify(otp_code) + result = totp.verify(otp_code) + print(f"Verificação OTP para {self.username}") + print(f"Código fornecido: {otp_code}") + print(f"Resultado da verificação: {'válido' if result else 'inválido'}") + return result def get_otp_uri(self): totp = pyotp.TOTP(self.otp_secret) @@ -234,40 +256,61 @@ class RolePermissao(Base): permissao = relationship("Permissao", back_populates="roles") # Remover o banco de dados existente (se existir) -if os.path.exists('database.db'): - os.remove('database.db') +if os.path.exists(db_path): + os.remove(db_path) -# Criar todas as tabelas novamente -Base.metadata.create_all(engine) - -# Criar roles iniciais -def create_initial_data(): - session = get_db_connection() +def init_database(): + """Inicializa o banco de dados com dados básicos""" + print("Inicializando banco de dados...") + + # Criar todas as tabelas + Base.metadata.create_all(engine) + + session = SessionLocal() try: - # Criar role de admin - admin_role = Role(nome="Administrador", nivel=1) - session.add(admin_role) - session.flush() # Para obter o ID da role - - # Criar usuário admin - admin = Usuario( - username="admin", - password="admin123", - is_admin=True - ) - admin.role_id = admin_role.id - session.add(admin) - - session.commit() - print(f"Segredo OTP do admin: {admin.otp_secret}") - print("Usuário admin criado com sucesso!") + # Verificar se já existe um admin + admin = session.query(Usuario).filter_by(username="admin").first() + if not admin: + print("Criando role de administrador...") + # Criar role de admin + admin_role = session.query(Role).filter_by(nome="Administrador").first() + if not admin_role: + admin_role = Role(nome="Administrador", nivel=1) + session.add(admin_role) + session.commit() + + print("Criando usuário admin...") + # Criar usuário admin + admin = Usuario( + username="admin", + password="admin123", + is_admin=True + ) + admin.email = "admin@example.com" + admin.role_id = admin_role.id + + session.add(admin) + session.commit() + + print("=== Usuário Admin Criado ===") + print(f"Username: admin") + print(f"Senha: admin123") + print(f"Email: {admin.email}") + print(f"OTP Secret: {admin.otp_secret}") + else: + print("Usuário admin já existe") + except Exception as e: - print(f"Erro ao criar dados iniciais: {e}") + print(f"Erro na inicialização do banco: {e}") session.rollback() + raise finally: session.close() +# Inicializar o banco de dados automaticamente quando o módulo for importado +init_database() + # Executar a criação dos dados iniciais if __name__ == "__main__": - create_initial_data() \ No newline at end of file + init_database() \ No newline at end of file diff --git a/templates/home.html b/templates/home.html index 31779b9..78bbb56 100644 --- a/templates/home.html +++ b/templates/home.html @@ -3,15 +3,26 @@ {% block title %}Início{% endblock %} {% block content %} -
-
-

Menu do Sistema

-
- {% for url, endpoint in links %} - - {{ endpoint|replace('_', ' ')|title }} - - {% endfor %} +
+
+
+

Menu do Sistema

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ {% for url, nome in links %} + + {{ nome }} + + {% endfor %} +
diff --git a/templates/login.html b/templates/login.html index 8834c99..3a40097 100644 --- a/templates/login.html +++ b/templates/login.html @@ -14,7 +14,13 @@ {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} -
{{ message }}
+
+ {{ message }} + {% if category == 'danger' %} +
+ Se o problema persistir, contate o administrador. + {% endif %} +
{% endfor %} {% endif %} {% endwith %} @@ -22,7 +28,8 @@
- +
@@ -30,7 +37,8 @@
- + Digite o código de 6 dígitos do seu aplicativo autenticador
From 1367389619b322075422ad97011d9033956ef00b Mon Sep 17 00:00:00 2001 From: LS Date: Mon, 24 Mar 2025 16:34:38 -0300 Subject: [PATCH 5/7] =?UTF-8?q?adicionado=20timeout=20no=20login=20e=20bot?= =?UTF-8?q?=C3=A3o=20de=20Sair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- create_admin.py | 117 ++++++++++++++++++++++++++++++------------ functions/database.py | 49 +++++++++++------- templates/base.html | 13 ++++- 3 files changed, 127 insertions(+), 52 deletions(-) diff --git a/create_admin.py b/create_admin.py index ee162d4..6544061 100644 --- a/create_admin.py +++ b/create_admin.py @@ -2,6 +2,7 @@ from functions.database import init_database, Usuario, get_db_connection import qrcode import os from pathlib import Path +import pyotp def create_admin_user(): try: @@ -15,40 +16,86 @@ def create_admin_user(): admin = db_session.query(Usuario).filter_by(username="admin").first() if admin: - print("\n=== Detalhes do Usuário Admin ===") - print(f"Username: admin") - print(f"Email: {admin.email}") - print(f"OTP Secret: {admin.otp_secret}") - - # Configurar diretório para o QR Code - home = Path.home() - qr_dir = home / '.local' / 'share' / 'controles' / 'qrcodes' - qr_dir.mkdir(parents=True, exist_ok=True) - - # Gerar QR Code - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(admin.get_otp_uri()) - qr.make(fit=True) - - # Salvar QR Code - qr_path = qr_dir / 'admin_qr.png' - img = qr.make_image(fill_color="black", back_color="white") - img.save(str(qr_path)) - - print(f"\nQR Code salvo em: {qr_path}") - print("\nPasso a passo para configurar o OTP:") - print("1. Instale um aplicativo autenticador no seu celular") - print(" (Google Authenticator, Microsoft Authenticator, etc)") - print("2. Abra o aplicativo") - print("3. Selecione a opção para adicionar uma nova conta") - print("4. Escaneie o QR Code gerado") - print("\nOU, se preferir configuração manual:") - print(f"1. Use o segredo: {admin.otp_secret}") - print("2. Nome da conta: admin") - print("3. Tipo: Baseado em tempo (TOTP)") + print("\n=== Usuário Admin Encontrado ===") else: - print("ERRO: Falha ao criar usuário admin!") + print("\n=== Criando Novo Usuário Admin ===") + # Criar usuário admin com novo segredo OTP + admin = Usuario( + username="admin", + password="admin123", + is_admin=True + ) + admin.email = "admin@example.com" + # Adicionar e fazer commit para obter o ID + db_session.add(admin) + db_session.commit() + + # Recarregar o usuário do banco para garantir dados atualizados + admin = db_session.query(Usuario).filter_by(username="admin").first() + + # Verificar e mostrar informações do OTP + print("\n=== Informações do Usuário Admin ===") + print(f"Username: admin") + print(f"Senha: admin123") + print(f"Email: {admin.email}") + print(f"Segredo OTP atual: {admin.otp_secret}") + + # Gerar URI do OTP usando o segredo armazenado + totp = pyotp.TOTP(admin.otp_secret) + otp_uri = totp.provisioning_uri( + name=admin.username, + issuer_name="Sistema de Gestão" + ) + + # Configurar diretório para o QR Code + home = Path.home() + qr_dir = home / '.local' / 'share' / 'controles' / 'qrcodes' + qr_dir.mkdir(parents=True, exist_ok=True) + + # Gerar e salvar QR Code + qr_path = qr_dir / 'admin_qr.png' + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(otp_uri) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + img.save(str(qr_path)) + + print("\n=== QR Code Gerado ===") + print(f"QR Code salvo em: {qr_path}") + print(f"URI do OTP: {otp_uri}") + + # Gerar código atual para verificação + current_code = totp.now() + print("\n=== Verificação do OTP ===") + print(f"Código OTP atual: {current_code}") + print(f"Verificação do código: {totp.verify(current_code)}") + + print("\n=== Instruções para Configuração ===") + print("1. Instale um aplicativo autenticador no seu celular") + print(" (Google Authenticator, Microsoft Authenticator, etc)") + print("2. Abra o aplicativo") + print("3. Selecione a opção para adicionar uma nova conta") + print("4. Escaneie o QR Code salvo em:", qr_path) + print("\nOU configure manualmente:") + print(f"- Nome da conta: {admin.username}") + print(f"- Segredo: {admin.otp_secret}") + print("- Tipo: Baseado em tempo (TOTP)") + print("- Algoritmo: SHA1") + print("- Dígitos: 6") + print("- Intervalo: 30 segundos") + + # Verificação final + print("\n=== Teste de Verificação ===") + test_code = totp.now() + print(f"Código de teste: {test_code}") + is_valid = admin.verify_otp(test_code) + print(f"Verificação do código: {'Sucesso' if is_valid else 'Falha'}") + + if not is_valid: + print("\nALERTA: Verificação do OTP falhou!") + print("Por favor, verifique se o segredo OTP está correto.") + except Exception as e: print(f"\nErro durante a execução: {e}") import traceback @@ -58,4 +105,10 @@ def create_admin_user(): db_session.close() if __name__ == "__main__": + # Remover banco de dados existente para começar limpo + db_path = Path.home() / '.local' / 'share' / 'controles' / 'database.db' + if db_path.exists(): + os.remove(db_path) + print("Banco de dados antigo removido.") + create_admin_user() \ No newline at end of file diff --git a/functions/database.py b/functions/database.py index 56504d0..ebb9ce1 100644 --- a/functions/database.py +++ b/functions/database.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine, Column, Integer, String, Boolean, Numeric, Date, ForeignKey +from sqlalchemy import create_engine, Column, Integer, String, Boolean, Numeric, Date, ForeignKey, DateTime from sqlalchemy.orm import relationship, sessionmaker from sqlalchemy.ext.declarative import declarative_base from werkzeug.security import generate_password_hash, check_password_hash @@ -190,42 +190,53 @@ class Usuario(Base): id = Column(Integer, primary_key=True, autoincrement=True) username = Column(String(50), unique=True, nullable=False) password_hash = Column(String(255), nullable=False) - email = Column(String(100), unique=True, nullable=True) - otp_secret = Column(String(32), nullable=True) - role_id = Column(Integer, ForeignKey('roles.id'), nullable=True) - setor_id = Column(Integer, ForeignKey('setores.id'), nullable=True) + email = Column(String(100), unique=True, nullable=False) + otp_secret = Column(String(32)) + role_id = Column(Integer, ForeignKey('roles.id')) + setor_id = Column(Integer, ForeignKey('setores.id')) ativo = Column(Boolean, default=True) is_admin = Column(Boolean, default=False) - + ultimo_login = Column(DateTime) + ultimo_logout = Column(DateTime) + motivo_logout = Column(String(100)) + role = relationship("Role", back_populates="usuarios") setor = relationship("Setor", back_populates="usuarios") def __init__(self, username, password, is_admin=False): self.username = username - self.set_password(password) - self.otp_secret = pyotp.random_base32() + self.password_hash = generate_password_hash(password) self.is_admin = is_admin + self.otp_secret = pyotp.random_base32() # Gerar segredo OTP na criação self.ativo = True - def set_password(self, password): - self.password_hash = generate_password_hash(password) - def check_password(self, password): - result = check_password_hash(self.password_hash, password) - print(f"Verificação de senha para {self.username}: {'sucesso' if result else 'falha'}") - return result + return check_password_hash(self.password_hash, password) def verify_otp(self, otp_code): + """Verifica se o código OTP fornecido é válido""" + if not self.otp_secret: + print(f"Erro: Usuário {self.username} não tem segredo OTP configurado") + return False + totp = pyotp.TOTP(self.otp_secret) - result = totp.verify(otp_code) - print(f"Verificação OTP para {self.username}") + is_valid = totp.verify(otp_code) + print(f"Verificando OTP para {self.username}") + print(f"Segredo: {self.otp_secret}") print(f"Código fornecido: {otp_code}") - print(f"Resultado da verificação: {'válido' if result else 'inválido'}") - return result + print(f"Resultado: {'válido' if is_valid else 'inválido'}") + return is_valid def get_otp_uri(self): + """Gera a URI para o QR code do OTP""" + if not self.otp_secret: + self.otp_secret = pyotp.random_base32() + totp = pyotp.TOTP(self.otp_secret) - return totp.provisioning_uri(self.username, issuer_name="Sistema de Gestão") + return totp.provisioning_uri( + name=self.username, + issuer_name="Sistema de Gestão" + ) class Role(Base): __tablename__ = 'roles' diff --git a/templates/base.html b/templates/base.html index d2f91cc..0a4cf99 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,6 +7,7 @@ {{ bootstrap.load_css() }} + {% if 'user_id' in session %} + {% endif %}
{% block content %}{% endblock %}
{{ bootstrap.load_js() }} + + \ No newline at end of file From bae6b1ae14ca227d7730e0d55d56a3e576fd72ea Mon Sep 17 00:00:00 2001 From: LS Date: Thu, 27 Mar 2025 14:34:16 -0300 Subject: [PATCH 6/7] Login finalizado, admin funcionando corretamente e sendo gerado oQRcode na raiz do projeto --- Makefile | 9 +++- admin_qr.png | Bin 0 -> 1250 bytes app.py | 105 +++++++++++++++++++++++++++++++++++++----- create_admin.py | 48 +++++++++++++------ functions/database.py | 2 +- templates/base.html | 15 ++++++ 6 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 admin_qr.png diff --git a/Makefile b/Makefile index bc6c28a..969466b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,12 @@ install: pip install -r requirements.txt -run: +clean: + rm -rf ~/.local/share/controles/database.db + rm -f admin_qr.png + +run: clean python app.py + +reset-admin: clean + python create_admin.py diff --git a/admin_qr.png b/admin_qr.png new file mode 100644 index 0000000000000000000000000000000000000000..270fcde8af350adea1ab24e3c350f30932056c08 GIT binary patch literal 1250 zcmV<81ReW{P) zU25z`6ojkO0zdY^EWk@??;uOMcxTd5ynsN@qoGCRq5qCF7jm80=}BC|n6ae!kf3Nz z*ZHv{{`s;0_6ZO`6j4MGMHEp)5k(YHL=i=l17&md0I)mva$DeDZVRwob_d*+eoH-9 zkINT4izuReh!RMW8Q9ztxng%*L#`K#fZHwZM*>KaT)*U5L=ojfl)bN_?bCJv+>hjm zm{6ACvhD(PrT(gC5k-{0O^HRZdkG=1J(EDlC~NU{`8S_M6j6SDEawxEfZHw}4)Db0 zfbs+E3u4;uzVTphB+9p=c#;Vy`?8n-M84N3cMT-@_C&s3+I^AaJ>_$rMHEp!K)H1j z;rZS1_4(cLwS~VeaCmQ@E5WAB zE)-bBGJ`8T5tHwK0yd%u8AjPr+7)FQ#Td0*1Bu8C0KNya{(N=lS=tq4BBg8BS79>w zOnMG+kujH$25m@8h6*Wsy zlEq`!#$u0o?bSq?N$IY^*rv5BF?kj#cbzs-kFY}86=fEsue&uM7>X_GM03XMT1*>O zNV}rUq4W%|&n_kqxb3CJ#>I36fqel&Dp_F{(*@j|CxAS%!;kbvqI?&MiFd037TDqg z=H}og&!Z{UtzE6VqRgTgkE}@)Xvv74O*0vZ<2h+plxdWsN0yB`hnL$5a#~s<%4ZPe z0Vv0qfi)_q)^3&P>C+*LEbWRii*jn-Y5p#0Ert<7&{jSwW)Nj2<>-+e#A-aUo@*OR z?keqy@(`5L)q`q}m9q2V;9_^c?Wx8LrRx&A144%CcSV^QSUF))CQ^)ITNisXZ&%zEFx1NQQE#TbnkdsK2kXG}rsaOMW+a-i(93WD)$fWj zlVV~9Q)h0a3=`X$l$vTFMVU;od$l;C2l$@#9q~c#rRcSiSL?1Q6Dj9(!&#kilsB~8 zag8HFR-dpaGbx82_byE9IE-eX_N%mBnu1AbSCk19TLalWh0`#Rs@TyOoJPyLv@6Oi zO6z?He>lW+&1HCAOjjVjE!hD+WBc9Hl}zyMnt_l{1N9#1jYRn_6g#1542!NVnCHRT zt!cI~sX?_kqD-V5a}%TD+?#>BCd~%e3-TF6nMg5vwL{jo_X(H7AM4R!l5o1oL0j%zTD1bMN8bNRa{EPo1{QsJn=-zyJUM M07*qoM6N<$f{tigL;wH) literal 0 HcmV?d00001 diff --git a/app.py b/app.py index afce09a..29b8924 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -from flask import Flask, request, render_template, redirect, url_for, flash, session +from flask import Flask, request, render_template, redirect, url_for, flash, session, jsonify from functions.database import ( Base, Militante, @@ -17,12 +17,14 @@ from functions.database import ( ) from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from datetime import datetime +from datetime import datetime, timedelta from flask_bootstrap import Bootstrap5 from routes.cota import cota_bp from functions.validations import validar_cpf from functools import wraps from pathlib import Path +from time import time +from create_admin import generate_qr_code app = Flask(__name__) app.secret_key = 'sua_chave_secreta_aqui' # Necessário para sessões do Flask @@ -41,6 +43,39 @@ def login_required(f): return f(*args, **kwargs) return decorated_function +# Decorator para verificar se a sessão expirou +def session_timeout(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + return redirect(url_for('login')) + + # Verificar se existe timestamp do último acesso + if 'last_activity' in session: + last_activity = datetime.fromtimestamp(session['last_activity']) + now = datetime.now() + + # Se passaram mais de 2 horas + if now - last_activity > timedelta(hours=2): + # Registrar o logout por timeout + try: + user = db_session.query(Usuario).get(session['user_id']) + if user: + user.ultimo_logout = datetime.now() + user.motivo_logout = "Timeout de sessão" + db_session.commit() + except Exception as e: + print(f"Erro ao registrar logout por timeout: {e}") + + session.clear() + flash('Sua sessão expirou. Por favor, faça login novamente.', 'warning') + return redirect(url_for('login')) + + # Atualizar timestamp de último acesso + session['last_activity'] = time() + return f(*args, **kwargs) + return decorated_function + # Rota raiz - redireciona para login se não estiver autenticado @app.route("/") def index(): @@ -85,6 +120,12 @@ def login(): print(f"Login bem sucedido para o usuário '{username}'") session['user_id'] = user.id session['is_admin'] = user.is_admin + session['last_activity'] = time() # Inicializar timestamp + + # Registrar horário do login + user.ultimo_login = datetime.now() + db_session.commit() + next_page = request.args.get('next') flash('Login realizado com sucesso!', 'success') return redirect(next_page or url_for('home')) @@ -101,6 +142,7 @@ def logout(): # Rota home (protegida) @app.route("/home") @login_required +@session_timeout def home(): """Página inicial do sistema""" try: @@ -137,6 +179,7 @@ def home(): # Rota para criar um novo militante @app.route("/militantes/novo", methods=["GET", "POST"]) @login_required +@session_timeout def novo_militante(): if request.method == "POST": cpf = request.form["cpf"] @@ -172,6 +215,7 @@ def novo_militante(): # Rota para listar militantes @app.route("/militantes") @login_required +@session_timeout def listar_militantes(): militantes = db_session.query(Militante).all() return render_template("listar_militantes.html", militantes=militantes) @@ -179,6 +223,7 @@ def listar_militantes(): # Rota para criar uma nova cota mensal @app.route("/cotas/novo", methods=["GET", "POST"]) @login_required +@session_timeout def nova_cota(): if request.method == "POST": cotas_mensais = CotaMensal( @@ -203,6 +248,7 @@ def nova_cota(): # Rota para listar cotas mensais @app.route("/cotas") @login_required +@session_timeout def listar_cotas(): cotas = db_session.query(CotaMensal).all() return render_template("listar_cotas.html", cotas=cotas) @@ -210,6 +256,7 @@ def listar_cotas(): # Rota para criar um novo pagamento @app.route("/pagamentos/novo", methods=["GET", "POST"]) @login_required +@session_timeout def novo_pagamento(): if request.method == "POST": pagamentos = Pagamento( @@ -234,6 +281,7 @@ def novo_pagamento(): # Rota para listar pagamentos @app.route("/pagamentos") @login_required +@session_timeout def listar_pagamentos(): pagamentos = db_session.query(Pagamento).all() return render_template("listar_pagamentos.html", pagamentos=pagamentos) @@ -241,6 +289,7 @@ def listar_pagamentos(): # Rota para criar um novo material vendido @app.route("/materiais/novo", methods=["GET", "POST"]) @login_required +@session_timeout def novo_material(): if request.method == "POST": materiais_vendidos = MaterialVendido( @@ -266,6 +315,7 @@ def novo_material(): # Rota para listar materiais vendidos @app.route("/materiais") @login_required +@session_timeout def listar_materiais(): materiais = db_session.query(MaterialVendido).all() return render_template("listar_materiais.html", materiais=materiais) @@ -273,6 +323,7 @@ def listar_materiais(): # Rota para criar uma nova venda de jornais avulsos @app.route("/jornais/novo", methods=["GET", "POST"]) @login_required +@session_timeout def nova_venda_jornal(): if request.method == "POST": vendas_jornais_avulsos = VendaJornalAvulso( @@ -297,6 +348,7 @@ def nova_venda_jornal(): # Rota para listar vendas de jornais avulsos @app.route("/jornais") @login_required +@session_timeout def listar_vendas_jornal(): vendas = db_session.query(VendaJornalAvulso).all() return render_template("listar_vendas_jornal.html", vendas=vendas) @@ -304,6 +356,7 @@ def listar_vendas_jornal(): # Rota para criar uma nova assinatura anual @app.route("/assinaturas/novo", methods=["GET", "POST"]) @login_required +@session_timeout def nova_assinatura(): if request.method == "POST": assinaturas_anuais = AssinaturaAnual( @@ -330,6 +383,7 @@ def nova_assinatura(): # Rota para listar assinaturas anuais @app.route("/assinaturas") @login_required +@session_timeout def listar_assinaturas(): assinaturas = db_session.query(AssinaturaAnual).all() return render_template("listar_assinaturas.html", assinaturas=assinaturas) @@ -337,6 +391,7 @@ def listar_assinaturas(): # Rota para criar um novo relatório de cotas mensais @app.route("/relatorios/cotas/novo", methods=["GET", "POST"]) @login_required +@session_timeout def novo_relatorio_cotas(): if request.method == "POST": relatorio_cotas_mensais = RelatorioCotasMensais( @@ -361,6 +416,7 @@ def novo_relatorio_cotas(): # Rota para listar relatórios de cotas mensais @app.route("/relatorios/cotas") @login_required +@session_timeout def listar_relatorios_cotas(): relatorios = db_session.query(RelatorioCotasMensais).all() return render_template("listar_relatorios_cotas.html", relatorios=relatorios) @@ -368,6 +424,7 @@ def listar_relatorios_cotas(): # Rota para criar um novo relatório de vendas de materiais @app.route("/relatorios/vendas/novo", methods=["GET", "POST"]) @login_required +@session_timeout def novo_relatorio_vendas(): if request.method == "POST": relatorio_vendas_materiais = RelatorioVendasMateriais( @@ -392,12 +449,14 @@ def novo_relatorio_vendas(): # Rota para listar relatórios de vendas de materiais @app.route("/relatorios/vendas") @login_required +@session_timeout def listar_relatorios_vendas(): relatorios = db_session.query(RelatorioVendasMateriais).all() return render_template("listar_relatorios_vendas.html", relatorios=relatorios) @app.route("/militantes/editar/", methods=["GET", "POST"]) @login_required +@session_timeout def editar_militante(id): militante = db_session.query(Militante).get(id) if not militante: @@ -469,17 +528,11 @@ def novo_usuario(): db_session.add(novo_usuario) db_session.commit() - # Gerar QR code para configuração do OTP + # Gerar QR code usando a função do create_admin.py qr_uri = novo_usuario.get_otp_uri() + qr_path = generate_qr_code(novo_usuario) + flash('Usuário criado com sucesso!', 'success') - - # Modificação alternativa para salvar no diretório atual - qr_path = Path('admin_qr.png') - img = qrcode.QRCode(version=1, box_size=10, border=5) - img.add_data(qr_uri) - img.make(fit=True) - img.save(str(qr_path)) - return render_template('mostrar_qr_code.html', qr_uri=qr_uri) except Exception as e: @@ -490,6 +543,30 @@ def novo_usuario(): return render_template('novo_usuario.html') +@app.route('/check_session') +def check_session(): + if 'last_activity' not in session: + return jsonify({'expired': True}) + + last_activity = datetime.fromtimestamp(session['last_activity']) + now = datetime.now() + + if now - last_activity > timedelta(hours=2): + # Registrar o logout por timeout + try: + user = db_session.query(Usuario).get(session.get('user_id')) + if user: + user.ultimo_logout = datetime.now() + user.motivo_logout = "Timeout de sessão" + db_session.commit() + except Exception as e: + print(f"Erro ao registrar logout por timeout: {e}") + + session.clear() + return jsonify({'expired': True}) + + return jsonify({'expired': False}) + def create_app(): app = Flask(__name__) # ... existing code ... @@ -501,4 +578,10 @@ def create_app(): # Iniciar o servidor Flask if __name__ == "__main__": + # Verificar se existe usuário admin e gerar QR code + admin = db_session.query(Usuario).filter_by(username="admin").first() + if admin: + print("\n=== Gerando QR Code para usuário admin existente ===") + generate_qr_code(admin) + app.run(debug=True) diff --git a/create_admin.py b/create_admin.py index 6544061..626221f 100644 --- a/create_admin.py +++ b/create_admin.py @@ -4,6 +4,36 @@ import os from pathlib import Path import pyotp +def generate_qr_code(user): + """ + Gera o QR code para um usuário específico + + Args: + user: Instância do modelo Usuario + + Returns: + Path: Caminho do arquivo QR code gerado + """ + import qrcode + + # Gerar QR Code apenas na raiz do projeto + qr_path = Path('admin_qr.png') + + # Remover arquivo antigo se existir + if qr_path.exists(): + os.remove(str(qr_path)) + + # Gerar e salvar QR Code + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(user.get_otp_uri()) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + img.save(str(qr_path)) + + print(f"\nQR Code gerado em: {os.path.abspath(qr_path)}") + + return qr_path + def create_admin_user(): try: # Inicializar o banco de dados @@ -17,6 +47,8 @@ def create_admin_user(): if admin: print("\n=== Usuário Admin Encontrado ===") + # Atualizar QR code mesmo se o usuário já existir + qr_path = generate_qr_code(admin) else: print("\n=== Criando Novo Usuário Admin ===") # Criar usuário admin com novo segredo OTP @@ -45,21 +77,11 @@ def create_admin_user(): totp = pyotp.TOTP(admin.otp_secret) otp_uri = totp.provisioning_uri( name=admin.username, - issuer_name="Sistema de Gestão" + issuer_name="Sistema de Controles" ) - # Configurar diretório para o QR Code - home = Path.home() - qr_dir = home / '.local' / 'share' / 'controles' / 'qrcodes' - qr_dir.mkdir(parents=True, exist_ok=True) - - # Gerar e salvar QR Code - qr_path = qr_dir / 'admin_qr.png' - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(otp_uri) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white") - img.save(str(qr_path)) + # Usar a função extraída para gerar o QR code + qr_path = generate_qr_code(admin) print("\n=== QR Code Gerado ===") print(f"QR Code salvo em: {qr_path}") diff --git a/functions/database.py b/functions/database.py index ebb9ce1..d3b16d4 100644 --- a/functions/database.py +++ b/functions/database.py @@ -235,7 +235,7 @@ class Usuario(Base): totp = pyotp.TOTP(self.otp_secret) return totp.provisioning_uri( name=self.username, - issuer_name="Sistema de Gestão" + issuer_name="Sistema de Controles" ) class Role(Base): diff --git a/templates/base.html b/templates/base.html index 0a4cf99..35dc81a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -48,5 +48,20 @@ {{ bootstrap.load_js() }} + + {% if 'user_id' in session %} + + {% endif %} \ No newline at end of file From 6370e8f39bfcf3ebe2ca56d17712dc13f3c7a67b Mon Sep 17 00:00:00 2001 From: LS Date: Thu, 27 Mar 2025 14:49:29 -0300 Subject: [PATCH 7/7] Atualizado o README.md --- .gitignore | 3 ++- README.md | 32 ++++++++++++++++++++++++++++++-- admin_qr.png | Bin 1250 -> 0 bytes 3 files changed, 32 insertions(+), 3 deletions(-) delete mode 100644 admin_qr.png diff --git a/.gitignore b/.gitignore index 73baf91..79f62d2 100644 --- a/.gitignore +++ b/.gitignore @@ -260,5 +260,6 @@ poetry.toml pyrightconfig.json database.db +admin_qr.png -# End of https://www.toptal.com/developers/gitignore/api/python,flask \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/python,flask diff --git a/README.md b/README.md index 62b96f3..c4a2e1c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# controles +# Sistema de Controles ## Para instalar @@ -6,9 +6,37 @@ make install ``` -## Para executar +## Sobre o QR Code de Autenticação (admin_qr.png) +O arquivo `admin_qr.png` é um QR code gerado automaticamente para configuração da autenticação de dois fatores (2FA) do usuário administrador. Este arquivo é: + +- Gerado na raiz do projeto quando: + - O comando `make reset-admin` é executado + - O servidor é iniciado com `make run` e existe um usuário admin + - Um novo usuário é criado através da interface web + +- Usado para: + - Configurar a autenticação 2FA no aplicativo autenticador (Google Authenticator, Microsoft Authenticator, etc.) + - Gerar os códigos OTP necessários para fazer login no sistema + +### Importante: +- O QR code é atualizado sempre que um novo usuário é criado ou quando o sistema é reiniciado +- Cada QR code é único e corresponde ao segredo OTP atual do usuário +- Se você recriar o banco de dados ou resetar o admin, será necessário reconfigurar o aplicativo autenticador com o novo QR code +- Mantenha este arquivo seguro, pois ele contém informações sensíveis de autenticação + +### Como usar: +1. Instale um aplicativo autenticador no seu celular (Google Authenticator, Microsoft Authenticator, etc.) +2. Escaneie o QR code (`admin_qr.png`) com o aplicativo +3. O aplicativo irá gerar códigos de 6 dígitos a cada 30 segundos +4. Use estes códigos junto com seu usuário e senha para fazer login no sistema + +### Comandos relacionados: ```bash +# Limpar banco e criar novo admin com novo QR code +make reset-admin + +# Iniciar o servidor (também gera novo QR code se necessário) make run ``` diff --git a/admin_qr.png b/admin_qr.png deleted file mode 100644 index 270fcde8af350adea1ab24e3c350f30932056c08..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1250 zcmV<81ReW{P) zU25z`6ojkO0zdY^EWk@??;uOMcxTd5ynsN@qoGCRq5qCF7jm80=}BC|n6ae!kf3Nz z*ZHv{{`s;0_6ZO`6j4MGMHEp)5k(YHL=i=l17&md0I)mva$DeDZVRwob_d*+eoH-9 zkINT4izuReh!RMW8Q9ztxng%*L#`K#fZHwZM*>KaT)*U5L=ojfl)bN_?bCJv+>hjm zm{6ACvhD(PrT(gC5k-{0O^HRZdkG=1J(EDlC~NU{`8S_M6j6SDEawxEfZHw}4)Db0 zfbs+E3u4;uzVTphB+9p=c#;Vy`?8n-M84N3cMT-@_C&s3+I^AaJ>_$rMHEp!K)H1j z;rZS1_4(cLwS~VeaCmQ@E5WAB zE)-bBGJ`8T5tHwK0yd%u8AjPr+7)FQ#Td0*1Bu8C0KNya{(N=lS=tq4BBg8BS79>w zOnMG+kujH$25m@8h6*Wsy zlEq`!#$u0o?bSq?N$IY^*rv5BF?kj#cbzs-kFY}86=fEsue&uM7>X_GM03XMT1*>O zNV}rUq4W%|&n_kqxb3CJ#>I36fqel&Dp_F{(*@j|CxAS%!;kbvqI?&MiFd037TDqg z=H}og&!Z{UtzE6VqRgTgkE}@)Xvv74O*0vZ<2h+plxdWsN0yB`hnL$5a#~s<%4ZPe z0Vv0qfi)_q)^3&P>C+*LEbWRii*jn-Y5p#0Ert<7&{jSwW)Nj2<>-+e#A-aUo@*OR z?keqy@(`5L)q`q}m9q2V;9_^c?Wx8LrRx&A144%CcSV^QSUF))CQ^)ITNisXZ&%zEFx1NQQE#TbnkdsK2kXG}rsaOMW+a-i(93WD)$fWj zlVV~9Q)h0a3=`X$l$vTFMVU;od$l;C2l$@#9q~c#rRcSiSL?1Q6Dj9(!&#kilsB~8 zag8HFR-dpaGbx82_byE9IE-eX_N%mBnu1AbSCk19TLalWh0`#Rs@TyOoJPyLv@6Oi zO6z?He>lW+&1HCAOjjVjE!hD+WBc9Hl}zyMnt_l{1N9#1jYRn_6g#1542!NVnCHRT zt!cI~sX?_kqD-V5a}%TD+?#>BCd~%e3-TF6nMg5vwL{jo_X(H7AM4R!l5o1oL0j%zTD1bMN8bNRa{EPo1{QsJn=-zyJUM M07*qoM6N<$f{tigL;wH)