initial login logic

This commit is contained in:
2026-01-19 20:12:50 -05:00
parent 523a4662b2
commit 96d252e47b
14 changed files with 537 additions and 0 deletions

20
.clinerules Normal file
View File

@@ -0,0 +1,20 @@
# Project Rules
## Tech Stack
- Python Flask webapp
- Uses Jinja Template for server side rendering
- No CSS framework, vanilla CSS with simple and plain design
- Sqlite3 for database
## Code Style
- Follow Python PEP 8 style guidelines
## Testing
- Write unit tests where it makes sense
- Use Python's unittest library
## Architecture
- Prefer server side rendering over client side rendering
- Use client side rendering only to dynamically update data when its necessary
- Use dependency injection for testability

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
SECRET_KEY=your-secret-key-here
DATABASE_URL=sqlite:///app.db
ALTCHA_HMAC_KEY=your-altcha-hmac-key-here

0
DEVLOG.md Normal file
View File

83
altcha_utils.py Normal file
View File

@@ -0,0 +1,83 @@
import hashlib
import hmac
import secrets
import base64
import json
import time
class AltchaChallenge:
"""Self-hosted Altcha challenge generator and verifier."""
def __init__(self, hmac_key=None):
self.hmac_key = hmac_key or secrets.token_hex(32)
def create_challenge(self, max_number=100000):
"""Generate a new Altcha challenge."""
salt = secrets.token_hex(12)
secret_number = secrets.randbelow(max_number) + 1
# Create the challenge string that needs to be solved
challenge_data = f"{salt}{secret_number}"
challenge = hashlib.sha256(challenge_data.encode()).hexdigest()
# Create signature for verification
signature = hmac.new(
self.hmac_key.encode(),
challenge.encode(),
hashlib.sha256
).hexdigest()
return {
"algorithm": "SHA-256",
"challenge": challenge,
"maxnumber": max_number,
"salt": salt,
"signature": signature
}
def verify_solution(self, payload):
"""Verify an Altcha solution payload."""
try:
# Decode base64 payload
decoded = base64.b64decode(payload).decode('utf-8')
data = json.loads(decoded)
algorithm = data.get('algorithm', '')
challenge = data.get('challenge', '')
number = data.get('number', 0)
salt = data.get('salt', '')
signature = data.get('signature', '')
# Verify algorithm
if algorithm.upper() != 'SHA-256':
return False
# Verify signature
expected_signature = hmac.new(
self.hmac_key.encode(),
challenge.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
return False
# Verify the solution
challenge_data = f"{salt}{number}"
computed_challenge = hashlib.sha256(challenge_data.encode()).hexdigest()
return hmac.compare_digest(computed_challenge, challenge)
except Exception:
return False
_altcha_instances = {}
def get_altcha(hmac_key):
"""Get or create an Altcha instance for the given HMAC key."""
if hmac_key not in _altcha_instances:
_altcha_instances[hmac_key] = AltchaChallenge(hmac_key)
return _altcha_instances[hmac_key]

37
app.py Normal file
View File

@@ -0,0 +1,37 @@
from flask import Flask
from flask_login import LoginManager
from config import Config
from models import db, User
from routes import main
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions
db.init_app(app)
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'main.signup'
login_manager.login_message = 'Please sign up or log in to access this page.'
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# Register blueprints
app.register_blueprint(main)
# Create database tables
with app.app_context():
db.create_all()
return app
if __name__ == '__main__':
app = create_app()
app.run(debug=True)

12
config.py Normal file
View File

@@ -0,0 +1,12 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///app.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
WTF_CSRF_ENABLED = True
ALTCHA_HMAC_KEY = os.environ.get('ALTCHA_HMAC_KEY') or 'dev-altcha-key-change-in-production'

48
forms.py Normal file
View File

@@ -0,0 +1,48 @@
import re
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, HiddenField
from wtforms.validators import (
DataRequired, Email, EqualTo, Length, ValidationError
)
def strong_password(form, field):
"""Validate password meets strong requirements."""
password = field.data
errors = []
if len(password) < 8:
errors.append("at least 8 characters")
if not re.search(r'[A-Z]', password):
errors.append("an uppercase letter")
if not re.search(r'[a-z]', password):
errors.append("a lowercase letter")
if not re.search(r'\d', password):
errors.append("a number")
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
errors.append("a special character (!@#$%^&*(),.?\":{}|<>)")
if errors:
raise ValidationError(f"Password must contain {', '.join(errors)}.")
class SignupForm(FlaskForm):
email = StringField('Email', validators=[
DataRequired(message="Email is required."),
Email(message="Please enter a valid email address."),
Length(max=255, message="Email must be less than 255 characters.")
])
password = PasswordField('Password', validators=[
DataRequired(message="Password is required."),
strong_password
])
confirm_password = PasswordField('Confirm Password', validators=[
DataRequired(message="Please confirm your password."),
EqualTo('password', message="Passwords must match.")
])
altcha = HiddenField('altcha', validators=[
DataRequired(message="Please complete the CAPTCHA challenge.")
])

32
models.py Normal file
View File

@@ -0,0 +1,32 @@
import os
import bcrypt
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
db = SQLAlchemy()
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, server_default=db.func.now())
def set_password(self, password):
"""Hash password with bcrypt (includes salt automatically)."""
salt = bcrypt.gensalt()
self.password_hash = bcrypt.hashpw(
password.encode('utf-8'), salt
).decode('utf-8')
def check_password(self, password):
"""Verify password against stored hash."""
return bcrypt.checkpw(
password.encode('utf-8'),
self.password_hash.encode('utf-8')
)
def __repr__(self):
return f'<User {self.email}>'

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
Flask-WTF==1.2.1
bcrypt==4.1.2
python-dotenv==1.0.0
email-validator==2.1.0
altcha==0.1.2

64
routes.py Normal file
View File

@@ -0,0 +1,64 @@
from flask import Blueprint, render_template, redirect, url_for, flash, jsonify, current_app
from flask_login import login_user, login_required, current_user
from models import db, User
from forms import SignupForm
from altcha_utils import get_altcha
main = Blueprint('main', __name__)
@main.route('/altcha/challenge')
def altcha_challenge():
"""Generate a new Altcha challenge for the signup form."""
altcha = get_altcha(current_app.config['ALTCHA_HMAC_KEY'])
challenge = altcha.create_challenge()
return jsonify(challenge)
@main.route('/')
def index():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
return redirect(url_for('main.signup'))
@main.route('/signup', methods=['GET', 'POST'])
def signup():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = SignupForm()
if form.validate_on_submit():
# Verify Altcha solution
altcha = get_altcha(current_app.config['ALTCHA_HMAC_KEY'])
if not altcha.verify_solution(form.altcha.data):
flash('CAPTCHA verification failed. Please try again.', 'error')
return render_template('signup.html', form=form)
# Check if user already exists
existing_user = User.query.filter_by(email=form.email.data.lower()).first()
if existing_user:
flash('An account with this email already exists.', 'error')
return render_template('signup.html', form=form)
# Create new user
user = User(email=form.email.data.lower())
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
# Auto-login after signup
login_user(user)
flash('Account created successfully!', 'success')
return redirect(url_for('main.dashboard'))
return render_template('signup.html', form=form)
@main.route('/dashboard')
@login_required
def dashboard():
return render_template('dashboard.html')

132
static/css/styles.css Normal file
View File

@@ -0,0 +1,132 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* Flash messages */
.flash {
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 4px;
}
.flash-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.flash-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
/* Auth container */
.auth-container {
max-width: 400px;
margin: 60px auto;
padding: 32px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.auth-container h1 {
margin-bottom: 24px;
font-size: 24px;
text-align: center;
}
/* Form styles */
.auth-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-weight: 500;
font-size: 14px;
}
.form-input {
padding: 10px 12px;
font-size: 16px;
border: 1px solid #ddd;
border-radius: 4px;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #007bff;
}
.error {
color: #dc3545;
font-size: 13px;
}
.hint {
color: #666;
font-size: 12px;
}
/* Buttons */
.btn {
padding: 12px 24px;
font-size: 16px;
font-weight: 500;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #007bff;
color: #fff;
}
.btn-primary:hover {
background-color: #0056b3;
}
/* Dashboard */
.dashboard-container {
max-width: 800px;
margin: 60px auto;
padding: 32px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dashboard-container h1 {
margin-bottom: 16px;
}
.dashboard-container p {
margin-bottom: 8px;
}

25
templates/base.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Kebuu{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

11
templates/dashboard.html Normal file
View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Dashboard - Kebuu{% endblock %}
{% block content %}
<div class="dashboard-container">
<h1>Welcome to your Dashboard</h1>
<p>Hello, {{ current_user.email }}!</p>
<p>You have successfully signed up and logged in.</p>
</div>
{% endblock %}

62
templates/signup.html Normal file
View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Sign Up - Kebuu{% endblock %}
{% block head %}
<script async defer src="https://cdn.altcha.org/js/latest/altcha.min.js" type="module"></script>
{% endblock %}
{% block content %}
<div class="auth-container">
<h1>Create Account</h1>
<form method="POST" action="{{ url_for('main.signup') }}" class="auth-form">
{{ form.hidden_tag() }}
<div class="form-group">
<label for="email">Email</label>
{{ form.email(class="form-input", placeholder="you@example.com", id="email") }}
{% if form.email.errors %}
{% for error in form.email.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
{% endif %}
</div>
<div class="form-group">
<label for="password">Password</label>
{{ form.password(class="form-input", id="password") }}
{% if form.password.errors %}
{% for error in form.password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
{% endif %}
<small class="hint">Must contain: 8+ characters, uppercase, lowercase, number, special character</small>
</div>
<div class="form-group">
<label for="confirm_password">Confirm Password</label>
{{ form.confirm_password(class="form-input", id="confirm_password") }}
{% if form.confirm_password.errors %}
{% for error in form.confirm_password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
{% endif %}
</div>
<div class="form-group">
<altcha-widget
challengeurl="{{ url_for('main.altcha_challenge') }}"
style="--altcha-max-width: 100%;"
></altcha-widget>
{% if form.altcha.errors %}
{% for error in form.altcha.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Sign Up</button>
</form>
</div>
{% endblock %}