From 96d252e47bdcaa7d75cab10d8410cfcba4069a755cf75f7c70363c3984139fd4 Mon Sep 17 00:00:00 2001 From: Timothy Kim Date: Mon, 19 Jan 2026 20:12:50 -0500 Subject: [PATCH] initial login logic --- .clinerules | 20 ++++++ .env.example | 3 + DEVLOG.md | 0 altcha_utils.py | 83 ++++++++++++++++++++++++ app.py | 37 +++++++++++ config.py | 12 ++++ forms.py | 48 ++++++++++++++ models.py | 32 ++++++++++ requirements.txt | 8 +++ routes.py | 64 +++++++++++++++++++ static/css/styles.css | 132 +++++++++++++++++++++++++++++++++++++++ templates/base.html | 25 ++++++++ templates/dashboard.html | 11 ++++ templates/signup.html | 62 ++++++++++++++++++ 14 files changed, 537 insertions(+) create mode 100644 .clinerules create mode 100644 .env.example create mode 100644 DEVLOG.md create mode 100644 altcha_utils.py create mode 100644 app.py create mode 100644 config.py create mode 100644 forms.py create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 routes.py create mode 100644 static/css/styles.css create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/signup.html diff --git a/.clinerules b/.clinerules new file mode 100644 index 0000000..a47c594 --- /dev/null +++ b/.clinerules @@ -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 + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3f70007 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +SECRET_KEY=your-secret-key-here +DATABASE_URL=sqlite:///app.db +ALTCHA_HMAC_KEY=your-altcha-hmac-key-here diff --git a/DEVLOG.md b/DEVLOG.md new file mode 100644 index 0000000..473a0f4 diff --git a/altcha_utils.py b/altcha_utils.py new file mode 100644 index 0000000..cc51c73 --- /dev/null +++ b/altcha_utils.py @@ -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] diff --git a/app.py b/app.py new file mode 100644 index 0000000..64eba1a --- /dev/null +++ b/app.py @@ -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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..c8ef2d9 --- /dev/null +++ b/config.py @@ -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' diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..f0d7128 --- /dev/null +++ b/forms.py @@ -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.") + ]) diff --git a/models.py b/models.py new file mode 100644 index 0000000..5866c74 --- /dev/null +++ b/models.py @@ -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'' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7171dc0 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..252660f --- /dev/null +++ b/routes.py @@ -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') diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..6110964 --- /dev/null +++ b/static/css/styles.css @@ -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; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..ddcbb38 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,25 @@ + + + + + + {% block title %}Kebuu{% endblock %} + + {% block head %}{% endblock %} + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..89a943d --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Kebuu{% endblock %} + +{% block content %} +
+

Welcome to your Dashboard

+

Hello, {{ current_user.email }}!

+

You have successfully signed up and logged in.

+
+{% endblock %} diff --git a/templates/signup.html b/templates/signup.html new file mode 100644 index 0000000..53ce96e --- /dev/null +++ b/templates/signup.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} + +{% block title %}Sign Up - Kebuu{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+

Create Account

+ +
+ {{ form.hidden_tag() }} + +
+ + {{ form.email(class="form-input", placeholder="you@example.com", id="email") }} + {% if form.email.errors %} + {% for error in form.email.errors %} + {{ error }} + {% endfor %} + {% endif %} +
+ +
+ + {{ form.password(class="form-input", id="password") }} + {% if form.password.errors %} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} + {% endif %} + Must contain: 8+ characters, uppercase, lowercase, number, special character +
+ +
+ + {{ form.confirm_password(class="form-input", id="confirm_password") }} + {% if form.confirm_password.errors %} + {% for error in form.confirm_password.errors %} + {{ error }} + {% endfor %} + {% endif %} +
+ +
+ + {% if form.altcha.errors %} + {% for error in form.altcha.errors %} + {{ error }} + {% endfor %} + {% endif %} +
+ + +
+
+{% endblock %}