DocsUnidade 3: usuários
Crie um aplicativo de notas com Nexy, parte 10: crie uma conta e faça login
A Parte 10 permite que um visitante crie uma conta com um e-mail e uma senha, faça login e logout e lembre quem está conectado com um cookie de sessão.
Antes de começar: o aplicativo da parte 9.
Ativar sessões
Uma sessão é um cookie assinado: o servidor coloca nele o ID do usuário conectado e o lê de volta em cada solicitação. Starlette assina com itsdangerous:
uv add itsdangerous
O segredo com o qual ele assina é mantido em .env, próximo ao endereço do banco de dados:
DATABASE_URL=sqlite:///dev.dbSESSION_SECRET=change-me-to-a-long-random-stringFaça um verdadeiro segredo com python -c "import secrets; print(secrets.token_urlsafe(32))". Qualquer pessoa que saiba disso pode assinar um cookie como qualquer usuário.
useSession ativa sessões, com o segredo lido do ambiente:
import os from dotenv import load_dotenv from nexy.core.models import NexyConfigModelfrom nexy.frontend import react load_dotenv() class NexyConfig(NexyConfigModel): useFF = react() useTitle = "Nexy Web (FBR + React)" useAliases = {"@": "src"} useVite = True useDevTools = True useSession = {"secret_key": os.environ["SESSION_SECRET"]}import os from dotenv import load_dotenv from nexy.core.models import NexyConfigModelfrom nexy.frontend import reactfrom src.apps.app_module import AppModule load_dotenv() class NexyConfig(NexyConfigModel): useFF = react() useTitle = "Nexy Web (Modular + React)" useRouter = AppModule useAliases = {"@": "src"} useVite = True useDevTools = True useSession = {"secret_key": os.environ["SESSION_SECRET"]}os.environ[...] e não os.getenv(...): sem segredo, o aplicativo para na inicialização em vez de funcionar sem nenhum.
Descreva um usuário
from sqlmodel import Field, SQLModel class User(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) email: str = Field(unique=True, index=True) password_hash: strUm usuário é um recurso próprio, em src/apps/users/:
from sqlmodel import Field, SQLModel class User(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) email: str = Field(unique=True, index=True) password_hash: strA tabela mantém um hash da senha, nunca da senha: um banco de dados roubado não revela as senhas. unique=True faz com que o banco de dados recuse uma segunda conta com o mesmo email.
nx migrate -m "create users"nx migrate --upgrade
A migração encontra o novo modelo por conta própria: ela lê cada *_model.py de src/.
Hash e verifique uma senha
hashlib.scrypt, da biblioteca padrão, é um hash lento propositalmente, portanto, adivinhar as senhas uma por uma custa anos ao invasor. Cada senha recebe seu próprio sal aleatório:
import hashlibimport hmacimport secrets from sqlalchemy.exc import IntegrityErrorfrom sqlmodel import Session, select from src.configs.db import enginefrom src.models.user_model import User def hash_password(password: str) -> str: salt = secrets.token_bytes(16) digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1) return f"{salt.hex()}:{digest.hex()}" def password_matches(password: str, stored: str) -> bool: salt, digest = stored.split(":") found = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=2**14, r=8, p=1) return hmac.compare_digest(found.hex(), digest) def create_user(email: str, password: str) -> User | None: """The new user, or None when the email already has an account.""" user = User(email=email, password_hash=hash_password(password)) with Session(engine) as session: session.add(user) try: session.commit() except IntegrityError: return None session.refresh(user) return user def authenticate(email: str, password: str) -> User | None: with Session(engine) as session: user = session.exec(select(User).where(User.email == email)).first() if user is None or not password_matches(password, user.password_hash): return None return userimport hashlibimport hmacimport secrets from sqlalchemy.exc import IntegrityErrorfrom sqlmodel import Session, select from src.apps.users.user_model import Userfrom src.configs.db import engine def hash_password(password: str) -> str: salt = secrets.token_bytes(16) digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1) return f"{salt.hex()}:{digest.hex()}" def password_matches(password: str, stored: str) -> bool: salt, digest = stored.split(":") found = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=2**14, r=8, p=1) return hmac.compare_digest(found.hex(), digest) def create_user(email: str, password: str) -> User | None: """The new user, or None when the email already has an account.""" user = User(email=email, password_hash=hash_password(password)) with Session(engine) as session: session.add(user) try: session.commit() except IntegrityError: return None session.refresh(user) return user def authenticate(email: str, password: str) -> User | None: with Session(engine) as session: user = session.exec(select(User).where(User.email == email)).first() if user is None or not password_matches(password, user.password_hash): return None return userhmac.compare_digest compara em um tempo que não depende de onde os dois hashes diferem, então o tempo de resposta não diz nada ao invasor.
Inscreva-se, faça login, saia
Três ações. Cada um que tiver sucesso grava a sessão ou a esvazia:
from urllib.parse import urlencode from nexy import useSessionfrom nexy.decorators import Actionfrom nexy.http import RedirectResponsefrom src.accounts import authenticate, create_user MIN_PASSWORD = 8 def go(path: str, error: str = "") -> RedirectResponse: query = f"?{urlencode({'error': error})}" if error else "" return RedirectResponse(f"{path}{query}", status_code=303) @Actionasync def sign_up(email: str, password: str) -> RedirectResponse: email = email.strip().lower() if len(password) < MIN_PASSWORD: return go("/signup", f"A password has at least {MIN_PASSWORD} characters.") user = create_user(email, password) if user is None: return go("/signup", "This email already has an account.") useSession()["user_id"] = user.id return go("/notes") @Actionasync def sign_in(email: str, password: str) -> RedirectResponse: user = authenticate(email.strip().lower(), password) if user is None: return go("/signin", "Wrong email or password.") useSession()["user_id"] = user.id return go("/notes") @Actionasync def sign_out() -> RedirectResponse: useSession().clear() return go("/")E-mail ou senha errada, nunca qual: uma mensagem que dizia “nenhuma conta com este e-mail” informaria a qualquer pessoa quais e-mails possuem uma conta.
As duas formas
from nexy import useSearchParams error = useSearchParams().get("error", "")<section class="app-page"> <h1>Create an account</h1> {% if error %} <p class="note-error" role="alert">{{ error | e }}</p> {% endif %} <form method="post" action="{{ __actions_path['sign_up'] }}"> <label>Email <input type="email" name="email" autocomplete="email" required></label> <label>Password <input type="password" name="password" autocomplete="new-password" minlength="8" required></label> <button type="submit">Create the account</button> </form> <a href="/signin">I already have an account</a></section>from nexy import useSearchParams error = useSearchParams().get("error", "")<section class="app-page"> <h1>Sign in</h1> {% if error %} <p class="note-error" role="alert">{{ error | e }}</p> {% endif %} <form method="post" action="{{ __actions_path['sign_in'] }}"> <label>Email <input type="email" name="email" autocomplete="email" required></label> <label>Password <input type="password" name="password" autocomplete="current-password" required></label> <button type="submit">Sign in</button> </form> <a href="/signup">Create an account</a></section>from nexy import useSearchParams error = useSearchParams().get("error", "")<section class="app-page"> <h1>Create an account</h1> {% if error %} <p class="note-error" role="alert">{{ error | e }}</p> {% endif %} <form method="post" action="{{ __actions_path['sign_up'] }}"> <label>Email <input type="email" name="email" autocomplete="email" required></label> <label>Password <input type="password" name="password" autocomplete="new-password" minlength="8" required></label> <button type="submit">Create the account</button> </form> <a href="/signin">I already have an account</a></section>from nexy import useSearchParams error = useSearchParams().get("error", "")<section class="app-page"> <h1>Sign in</h1> {% if error %} <p class="note-error" role="alert">{{ error | e }}</p> {% endif %} <form method="post" action="{{ __actions_path['sign_in'] }}"> <label>Email <input type="email" name="email" autocomplete="email" required></label> <label>Password <input type="password" name="password" autocomplete="current-password" required></label> <button type="submit">Sign in</button> </form> <a href="/signup">Create an account</a></section>from nexy.decorators import Controllerfrom nexy.hooks import useViews LAYOUT = "src/apps/layout.nexy" def page(view: str): return useViews(LAYOUT, context={"children": useViews(view)}) @Controller("/signup")class SignUpController: async def get(self): return page("src/apps/users/signup_view.nexy") @Controller("/signin")class SignInController: async def get(self): return page("src/apps/users/signin_view.nexy")from nexy.decorators import Module from .app_controller import AboutController, AppControllerfrom .notes.notes_controller import NoteController, NotesApiController, NotesControllerfrom .users.users_controller import SignInController, SignUpController @Module()class AppModule: controllers = [ AppController, AboutController, NotesController, NoteController, NotesApiController, SignUpController, SignInController, ]autocomplete="new-password" permite que um gerenciador de senhas ofereça uma senha forte e current-password permite que ele preencha aquela que ele salvou.
Mostrar quem está conectado
A página de notas cumprimenta o usuário e mostra o botão de sair; em vez disso, um visitante vê os links para os dois formulários:
from "@/components/NoteCard.nexy" import NoteCardfrom nexy import useSearchParams, useSessionfrom src.notes import notes_page params = useSearchParams()error = params.get("error", "")asked = params.get("page", "1")number = max(int(asked), 1) if asked.isdigit() else 1found = notes_page(number)notes = found[0]more = found[1]signed_in = "user_id" in useSession()<section class="app-page"> <h1>Notes</h1> {% if signed_in %} <form method="post" action="{{ __actions_path['sign_out'] }}"> <button type="submit">Sign out</button> </form> {% else %} <p><a href="/signin">Sign in</a> or <a href="/signup">create an account</a>.</p> {% endif %} {% if error %} <p class="note-error" role="alert">{{ error | e }}</p> {% endif %} <form method="post" action="{{ __actions_path['create_note'] }}"> <input name="text" placeholder="A new note" maxlength="200" required> <button type="submit">Add</button> </form> {% for note in notes %} <NoteCard id="{{ note.id }}" text="{{ note.text }}" /> {% endfor %} <nav class="note-pages" aria-label="Pages of notes"> {% if number > 1 %}<a href="/notes?page={{ number - 1 }}" rel="prev">Newer</a>{% endif %} {% if more %}<a href="/notes?page={{ number + 1 }}" rel="next">Older</a>{% endif %} </nav></section>from "@/components/NoteCard.nexy" import NoteCardfrom nexy import useSearchParams, useSessionfrom src.notes import notes_page params = useSearchParams()error = params.get("error", "")asked = params.get("page", "1")number = max(int(asked), 1) if asked.isdigit() else 1found = notes_page(number)notes = found[0]more = found[1]signed_in = "user_id" in useSession()<section class="app-page"> <h1>Notes</h1> {% if signed_in %} <form method="post" action="{{ __actions_path['sign_out'] }}"> <button type="submit">Sign out</button> </form> {% else %} <p><a href="/signin">Sign in</a> or <a href="/signup">create an account</a>.</p> {% endif %} {% if error %} <p class="note-error" role="alert">{{ error | e }}</p> {% endif %} <form method="post" action="{{ __actions_path['create_note'] }}"> <input name="text" placeholder="A new note" maxlength="200" required> <button type="submit">Add</button> </form> {% for note in notes %} <NoteCard id="{{ note.id }}" text="{{ note.text }}" /> {% endfor %} <nav class="note-pages" aria-label="Pages of notes"> {% if number > 1 %}<a href="/notes?page={{ number - 1 }}" rel="prev">Newer</a>{% endif %} {% if more %}<a href="/notes?page={{ number + 1 }}" rel="next">Older</a>{% endif %} </nav></section>Abra http://localhost:3000/signup, crie uma conta: você acessa as notas com um botão Sair. Saia e entre novamente em http://localhost:3000/signin.
O que você aprendeu
useSessionemnexyconfig.pyativa um cookie de sessão assinado; seu segredo mora em.env.- O banco de dados mantém um hash lento e salgado da senha, verificado com
hmac.compare_digest. - Uma Action grava a sessão com
useSession(); uma página lê da mesma maneira.
Próximas etapas
- Parte 11: dê a cada usuário suas próprias notas
- Middleware e segurança: sessões, CORS e hosts confiáveis