Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ export const en = {
registered: 'Account created with the default user role. Sign in with your new credentials.',
username: 'Username',
password: 'Password',
confirmPassword: 'Confirm password',
passwordRequirements: 'Password requirements',
passwordMinLength: 'At least 6 characters',
passwordsMatch: 'Passwords match',
passwordAvoidCommon: 'Avoid common passwords',
signingIn: 'Signing in...',
forgotPassword: 'Forgot password?',
needAccount: 'Need an account? Create one',
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ export const es = {
registered: 'Cuenta creada con el rol de usuario predeterminado. Inicia sesión con tus nuevas credenciales.',
username: 'Usuario',
password: 'Contraseña',
confirmPassword: 'Confirmar contraseña',
passwordRequirements: 'Requisitos de contraseña',
passwordMinLength: 'Al menos 6 caracteres',
passwordsMatch: 'Las contraseñas coinciden',
passwordAvoidCommon: 'Evita contraseñas comunes',
signingIn: 'Iniciando sesión...',
forgotPassword: '¿Olvidaste tu contraseña?',
needAccount: '¿Necesitas una cuenta? Crea una',
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/lib/validation/auth-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { registerSchema } from './auth-schemas'

const validRegistration = {
name: 'Py',
last_name: 'Tester',
email: 'pytest@example.com',
username: 'pytest_user',
password: 'pytest_password',
confirmPassword: 'pytest_password',
preferred_language: 'es' as const,
}

describe('registerSchema', () => {
it('rejects passwords shorter than 6 characters', () => {
const parsed = registerSchema.safeParse({
...validRegistration,
password: 'short',
confirmPassword: 'short',
})

expect(parsed.success).toBe(false)
})

it('rejects mismatched password confirmation', () => {
const parsed = registerSchema.safeParse({
...validRegistration,
confirmPassword: 'different_password',
})

expect(parsed.success).toBe(false)
})

it('rejects common passwords', () => {
const parsed = registerSchema.safeParse({
...validRegistration,
password: 'password1234',
confirmPassword: 'password1234',
})

expect(parsed.success).toBe(false)
})

it('accepts matching 6 character or longer passwords', () => {
const parsed = registerSchema.safeParse(validRegistration)

expect(parsed.success).toBe(true)
})
})
27 changes: 26 additions & 1 deletion frontend/src/lib/validation/auth-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { z } from 'zod'

export const PASSWORD_MIN_LENGTH = 6
export const PASSWORD_MAX_LENGTH = 1024

export const COMMON_PASSWORDS = [
'password',
'password123',
'password1234',
'123456789012',
'qwerty123456',
'adminadmin123',
'letmein123456',
'welcome12345',
]

export function isCommonPassword(password: string) {
return COMMON_PASSWORDS.includes(password.trim().toLowerCase())
}

export const loginSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
Expand All @@ -10,8 +28,15 @@ export const registerSchema = z.object({
last_name: z.string().min(1, 'Last name is required'),
email: z.string().email('Enter a valid email'),
username: z.string().min(3, 'Username must be at least 3 characters'),
password: z.string().min(6, 'Password must be at least 6 characters'),
password: z.string()
.min(PASSWORD_MIN_LENGTH, `Password must be at least ${PASSWORD_MIN_LENGTH} characters`)
.max(PASSWORD_MAX_LENGTH, 'Password does not meet the requirements')
.refine((password) => !isCommonPassword(password), 'Password does not meet the requirements'),
confirmPassword: z.string().min(1, 'Confirm password is required'),
preferred_language: z.enum(['es', 'en']),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
})

export const recoveryRequestSchema = z.object({
Expand Down
32 changes: 29 additions & 3 deletions frontend/src/pages/register/register-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Select } from '../../components/ui/select'
import { ThemeToggle } from '../../components/ui/theme-toggle'
import { useAuth } from '../../features/auth/auth-store'
import { ApiError } from '../../lib/http/api-error'
import { registerSchema } from '../../lib/validation/auth-schemas'
import { PASSWORD_MIN_LENGTH, isCommonPassword, registerSchema } from '../../lib/validation/auth-schemas'

export function RegisterPage() {
const { t, i18n } = useTranslation()
Expand All @@ -22,6 +22,7 @@ export function RegisterPage() {
email: '',
username: '',
password: '',
confirmPassword: '',
preferred_language: i18n.resolvedLanguage === 'en' ? 'en' : 'es',
})
const [error, setError] = useState<string | null>(null)
Expand All @@ -35,6 +36,13 @@ export function RegisterPage() {
setForm((current) => ({ ...current, [field]: value }))
}

const passwordLengthMet = form.password.length >= PASSWORD_MIN_LENGTH
const commonPasswordMet = form.password.length > 0 && !isCommonPassword(form.password)
const passwordsMatch = form.confirmPassword.length > 0 && form.password === form.confirmPassword
const passwordRequirementsMet = passwordLengthMet && commonPasswordMet && passwordsMatch

const requirementClass = (isMet: boolean) => (isMet ? 'requirement-met' : 'requirement-unmet')

const onSubmit = async (event: FormEvent) => {
event.preventDefault()
const parsed = registerSchema.safeParse(form)
Expand All @@ -46,7 +54,14 @@ export function RegisterPage() {
try {
setLoading(true)
setError(null)
await signup(parsed.data)
await signup({
name: parsed.data.name,
last_name: parsed.data.last_name,
email: parsed.data.email,
username: parsed.data.username,
password: parsed.data.password,
preferred_language: parsed.data.preferred_language,
})
navigate('/app/prompts', { replace: true })
} catch (err) {
const message = err instanceof Error ? err.message : t('auth.unableToCreate')
Expand Down Expand Up @@ -80,6 +95,17 @@ export function RegisterPage() {
<Input label={t('auth.email')} value={form.email} onChange={(event) => updateField('email', event.target.value)} autoComplete="email" />
<Input label={t('auth.username')} value={form.username} onChange={(event) => updateField('username', event.target.value)} autoComplete="username" />
<Input label={t('auth.password')} type="password" value={form.password} onChange={(event) => updateField('password', event.target.value)} autoComplete="new-password" />
<Input label={t('auth.confirmPassword')} type="password" value={form.confirmPassword} onChange={(event) => updateField('confirmPassword', event.target.value)} autoComplete="new-password" />
<div className="password-requirements" aria-live="polite">
<p className="muted form-helper">{t('auth.passwordRequirements')}</p>
<ul>
<li className={requirementClass(passwordLengthMet)}>{t('auth.passwordMinLength')}</li>
<li className={requirementClass(commonPasswordMet)}>{t('auth.passwordAvoidCommon')}</li>
{form.confirmPassword.length > 0 ? (
<li className={requirementClass(passwordsMatch)}>{t('auth.passwordsMatch')}</li>
) : null}
</ul>
</div>
<Select
label={t('auth.preferredLanguage')}
options={languageOptions}
Expand All @@ -88,7 +114,7 @@ export function RegisterPage() {
/>
<p className="muted form-helper">{t('auth.preferredLanguageHelp')}</p>
{error ? <InlineError message={error} /> : null}
<Button type="submit" disabled={loading}>{loading ? t('auth.creating') : t('nav.createAccount')}</Button>
<Button type="submit" disabled={loading || !passwordRequirementsMet}>{loading ? t('auth.creating') : t('nav.createAccount')}</Button>
<Link className="text-link" to="/login">{t('auth.alreadyHaveAccount')}</Link>
<Link className="text-link" to="/">{t('auth.backToLanding')}</Link>
</form>
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,27 @@ textarea:focus-visible {
margin-top: calc(var(--space-2) * -1);
}

.password-requirements {
display: grid;
gap: var(--space-2);
}

.password-requirements ul {
display: grid;
gap: var(--space-1);
margin: 0;
padding-left: 1.2rem;
font-size: var(--font-sm);
}

.requirement-met {
color: var(--success);
}

.requirement-unmet {
color: var(--muted);
}

.list {
display: grid;
gap: var(--space-3);
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
passlib[bcrypt]~=1.7.4
argon2-cffi~=25.1.0
SQLAlchemy~=2.0.41
sqlmodel~=0.0.24
uvicorn~=0.35.0
Expand Down
6 changes: 3 additions & 3 deletions webapi/admin_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
import os
from dataclasses import dataclass

from passlib.hash import sha256_crypt
from sqlalchemy.engine import make_url
from sqlalchemy.exc import OperationalError
from sqlmodel import SQLModel, Session, create_engine, select

from core import config
from auth.password_service import hash_password
from models.user import User


Expand Down Expand Up @@ -49,7 +49,7 @@ def bootstrap_super_admin(
user.name = name
user.last_name = last_name
user.email = email
user.hashed_password = sha256_crypt.hash(password)
user.hashed_password = hash_password(password)
user.role = SUPER_ADMIN_ROLE
action = "promoted"
else:
Expand All @@ -58,7 +58,7 @@ def bootstrap_super_admin(
name=name,
last_name=last_name,
email=email,
hashed_password=sha256_crypt.hash(password),
hashed_password=hash_password(password),
role=SUPER_ADMIN_ROLE,
)
action = "created"
Expand Down
6 changes: 3 additions & 3 deletions webapi/api/endpoints/v1/auths.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
from sqlmodel import Session, select
from models.user import User
from models.prompts import Prompts
from passlib.hash import sha256_crypt
from db.db_connection import get_session
from auth.auth_service import authenticate_user, crear_jwt, get_current_user, get_current_db_user
from auth.password_service import hash_password
from core.creator_prompts import get_creator_prompt_seeds
from infrastructure.email.smtp_service import send_email
import secrets
Expand Down Expand Up @@ -48,7 +48,7 @@ def signup(
name=payload.name,
last_name=payload.last_name,
email=payload.email,
hashed_password=sha256_crypt.hash(payload.password),
hashed_password=hash_password(payload.password),
preferred_language=payload.preferred_language,
role="user",
)
Expand Down Expand Up @@ -151,7 +151,7 @@ async def generate_password(
raise HTTPException(status_code=400, detail="Key already exists")
"""Generate and store a temporary password"""
password = secrets.token_urlsafe(16)
user.hashed_password = sha256_crypt.hash(password)
user.hashed_password = hash_password(password)
session.add(user)
session.commit()
session.refresh(user)
Expand Down
4 changes: 2 additions & 2 deletions webapi/api/endpoints/v1/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from schemas.user_schema import UserRead, UserReadWithPrompts
from db.db_connection import get_session
from auth.auth_service import get_current_user
from passlib.hash import sha256_crypt
from auth.password_service import hash_password


router = APIRouter()
Expand Down Expand Up @@ -53,7 +53,7 @@ def update_user(user_id: int, user: User,
existing_user.name = user.name
existing_user.last_name = user.last_name
existing_user.email = user.email
existing_user.hashed_password = sha256_crypt.hash(user.hashed_password)
existing_user.hashed_password = hash_password(user.hashed_password)
# Ensure the username is unique
statement = select(User).where(User.username == user.username, User.id != user_id)
if session.exec(statement).first():
Expand Down
15 changes: 13 additions & 2 deletions webapi/auth/auth_service.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import logging
import re
from datetime import datetime, timedelta
from typing import Optional

import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from passlib.hash import sha256_crypt
from sqlmodel import Session, select

from core import config
from auth.password_service import hash_password, password_hash_needs_update, verify_password
from db.db_connection import get_session
from dotenv import load_dotenv
from models.user import User
Expand All @@ -18,12 +19,22 @@
SECRET_KEY = config.JWT_SECRET_KEY
ALGORITHM = config.JWT_ALGORITHM
ACCESS_TOKEN_EXPIRE_MINUTES = config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
LOGGER = logging.getLogger(__name__)


def authenticate_user(username: str, password: str, session: Session = Depends(get_session)):
user = session.exec(select(User).where(User.username == username)).first()
if not user or not sha256_crypt.verify(password, user.hashed_password):
if not user or not verify_password(password, user.hashed_password):
return None
if password_hash_needs_update(user.hashed_password):
try:
user.hashed_password = hash_password(password)
session.add(user)
session.commit()
session.refresh(user)
except Exception: # pylint: disable=broad-exception-caught
session.rollback()
LOGGER.warning("password_rehash_failed user_id=%s", user.id, exc_info=True)
return user


Expand Down
27 changes: 27 additions & 0 deletions webapi/auth/password_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from passlib.context import CryptContext
from passlib.exc import UnknownHashError


password_context = CryptContext(
schemes=["argon2", "sha256_crypt"],
deprecated=["sha256_crypt"],
argon2__type="ID",
)


def hash_password(password: str) -> str:
return password_context.hash(password)


def verify_password(password: str, password_hash: str) -> bool:
try:
return password_context.verify(password, password_hash)
except (TypeError, ValueError, UnknownHashError):
return False


def password_hash_needs_update(password_hash: str) -> bool:
try:
return password_context.needs_update(password_hash)
except (TypeError, ValueError, UnknownHashError):
return False
Loading
Loading