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:

.env
DATABASE_URL=sqlite:///dev.dbSESSION_SECRET=change-me-to-a-long-random-string

Faç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:

nexyconfig.py
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"]}
nexyconfig.py
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

src/models/user_model.py
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: str

Um usuário é um recurso próprio, em src/apps/users/:

src/apps/users/user_model.py
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: str

A 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:

src/contas.py
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 user
src/contas.py
import 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 user

hmac.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:

src/actions/accounts.py
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

src/routes/signup.nexy
---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>
src/routes/signin.nexy
---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>
src/apps/users/signup_view.nexy
---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>
src/apps/users/signin_view.nexy
---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>
src/apps/users/users_controller.py
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")
src/apps/app_module.py
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:

src/rotas/notes/index.nexy
---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>
src/apps/notes/notes_view.nexy
---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

  • useSession em nexyconfig.py ativa 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

Editar no GitHub

Enter abre o resultado, Esc fecha.