initial login logic
This commit is contained in:
20
.clinerules
Normal file
20
.clinerules
Normal 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
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DATABASE_URL=sqlite:///app.db
|
||||
ALTCHA_HMAC_KEY=your-altcha-hmac-key-here
|
||||
83
altcha_utils.py
Normal file
83
altcha_utils.py
Normal 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
37
app.py
Normal 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
12
config.py
Normal 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
48
forms.py
Normal 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
32
models.py
Normal 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
8
requirements.txt
Normal 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
64
routes.py
Normal 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
132
static/css/styles.css
Normal 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
25
templates/base.html
Normal 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
11
templates/dashboard.html
Normal 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
62
templates/signup.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user