Вход

Аккаунты для приложения с бэкендом. Ты называешь одну модель базы данных пользователем; платформа хранит пароли, коды из писем и устройства и сообщает каждому эндпоинту, кто его вызывает. Всё это серверный код: db.auth. Приложение лишь вызывает твои эндпоинты — оно никогда не видит ни токена, ни хеша пароля.

Обзор

TypeScript
// account.server.ts — выполняется на бэкенде приложения
const User = model({
  name: t.string().default(""),
  notes: t.many("Note"),
})
const Note = model({
  text: t.string(),
  owner: t.one("User").onDelete("cascade"),
})
const db = defineDb({ User, Note }).withAuth({ model: "User" })

export type Me = { id: number, name: string }

export async function register(login: string, password: string, name: string): Promise<Me> {
  await db.auth.signUpWithPassword(login, password, { name })
  return db.auth.requireUser().select({ id: true, name: true })
}

export async function login(login: string, password: string): Promise<Me> {
  await db.auth.signInWithPassword(login, password)
  return db.auth.requireUser().select({ id: true, name: true })
}

export async function logout() {
  await db.auth.signOut()
}

/** Под кем вошло это устройство, или null. */
export async function me(): Promise<Me | null> {
  return db.auth.user().select({ id: true, name: true })
}

export async function addNote(text: string) {
  const user = await db.auth.requireUser().select({ id: true })      // гость получает здесь 401
  return db.note.insert({ text, owner: user })
}

export async function myNotes() {
  const user = await db.auth.requireUser().select({ id: true })
  return db.note.where({ owner: { id: user.id } }).select({ id: true, text: true })
}
TypeScript
// main.ts — приложение
import { login, logout, me, register, type Me } from "./account.server"

const Field = (placeholder: string, name: string) =>
  UIInput().style({ height: 44, px: 12, borderRadius: 10, bgColor: "white", color: "black", placeholder }).named(name)
const Action = (label: string, name: string, run: () => Promise<void>) =>
  UIButton(UIText(label).style({ color: "white" }))
    .style({ height: 44, borderRadius: 10, bgColor: "#4f7cff" })
    .named(name)
    .onClick(() => { run().catch(failed) })

const status = UIText("").style({ color: "white", fontSize: 20 })
const who = Field("Login or email", "login")
const password = Field("Password, 8 characters or more", "password").style({ type: "password" })
const form = UIColumn(
  who, password,
  Action("Sign in", "signin", async () => show(await login(who.value, password.value))),
  Action("Create account", "signup", async () => show(await register(who.value, password.value, who.value))),
).style({ gap: 10 })
const signOut = Action("Sign out", "signout", async () => { await logout(); show(null) })

// то, что отклонил сервер, говорится человеку: "Invalid login or password", "This login is taken"
const failed = (e: unknown) => toast(e instanceof RpcError ? e.message : String(e))

const show = (user: Me | null) => {
  status.text = user ? `Hello, ${user.name}` : "Sign in"
  form.style({ display: user ? "none" : "flex" })
  signOut.style({ display: user ? "flex" : "none" })
  password.value = ""
}

UIScreen(status, form, signOut).style({ bgColor: "#1e2230", p: 20, pt: "safe-top", gap: 16 }).open()

// устройство помнит свою сессию: после перезапуска `me()` отвечает тем же аккаунтом
me().then(show, failed)

Включение

TypeScript
const db = defineDb({ User, Note }).withAuth({ model: "User" })
  • withAuth — часть определения: пиши его на самом defineDb(...).
  • Модель пользователя — обычная твоя модель: добавь то, что приложение знает о человеке — имя, роль, аватар. Два правила:
    • каждое поле .optional() или имеет .default(): платформа вставляет строку при первом входе человека и не знает, что в неё положить;
    • она сохраняет встроенный id.
  • В строке пользователя нет ничего секретного. Логины, email и хеши паролей платформа хранит отдельно от твоих моделей.

Кто вызывает

TypeScript
db.auth.user()                    // строка вошедшего пользователя, или null для гостя
db.auth.requireUser()             // строка; гость → вызов отклоняется с 401 "Sign in required"
db.auth.requireUser(filter)       // строка; пользователь, не подходящий под фильтр → 403 "Forbidden"
  .select(shape)                  // какие поля читать, как в запросе; без него: id и простые поля

Это запросы к твоей модели пользователя, для пользователя этого запроса. Читай то, что нужно эндпоинту:

TypeScript
export async function addNote(text: string) {
  const user = await db.auth.requireUser().select({ id: true })
  return db.note.insert({ text, owner: user })
}

export async function profile() {
  return db.auth.requireUser().select({ name: true, notes: { text: true } })
  // { name: "Ann", notes: [{ text: "milk" }] }
}

Фильтр делает это ещё и проверкой доступа. Это where модели пользователя:

TypeScript
const User = model({ name: t.string().default(""), role: t.enum("member", "admin").default("member") })

export async function allUsers() {
  await db.auth.requireUser({ role: "admin" }).select({ id: true })      // 401 для гостя, 403 для member
  return db.user.select({ id: true, name: true, role: true })
}
Note

эти запросы нужно ждать через await (или возвращать). Голый db.auth.requireUser() ничего не выполняет и потому ничего не защищает — поэтому он и не компилируется: the user of a request is read with await — a query that is not awaited checks nothing.

Правило для данных, которые кому-то принадлежат: бери пользователя из db.auth, никогда из аргумента. Id, который присылает приложение, может быть чьим угодно.

TypeScript
export async function removeNote(id: number) {
  const user = await db.auth.requireUser().select({ id: true })
  // фильтр и есть проверка: чужая заметка не находится
  const removed = await db.note.where({ id, owner: { id: user.id } }).deleteMany()
  if (!removed) throw new ApiError(404, "No such note")
}

Пароли

TypeScript
await db.auth.signUpWithPassword(login, password, data?)   // новый аккаунт; это устройство входит под ним
await db.auth.signInWithPassword(login, password)          // это устройство вошло
await db.auth.setPassword(password)                        // новый пароль вошедшего пользователя
await db.auth.signOut()                                    // это устройство снова гость
  • login — то, что человек вводит для входа: email (в нём есть @) или имя из 3–32 букв, цифр, ., _, -. Регистр не важен: ANN входит как ann.
  • data — поля строки нового пользователя: { name }.
  • Пароль — от 8 до 128 символов.
  • setPassword разлогинивает все остальные устройства пользователя.
  • На email, указанный как логин, ничего не отправляется, и ничто его не подтверждает. Чтобы знать, что адрес принадлежит человеку, впусти его по коду.

Что получает неверный ввод — ApiError, так что вызов приложения отклоняется с этим статусом и сообщением:

статус сообщение когда
400 Password must be at least 8 characters регистрация, setPassword
400 Login must be 3–32 letters, digits, dots, dashes or underscores регистрация
400 Invalid email регистрация с @ в логине
409 This login is taken / An account with this email already exists регистрация
401 Invalid login or password вход: одно сообщение на любую причину
429 Too many attempts — try again later 10 неудачных входов для одного логина за 10 минут

Коды на email

Вход без пароля: на почту отправляется 6-значный код, человек его вводит.

TypeScript
export async function sendCode(email: string) {
  await db.auth.sendCode(email)
}

export async function signInWithCode(email: string, code: string): Promise<Me> {
  await db.auth.signInWithCode(email, code)
  return db.auth.requireUser().select({ id: true, name: true })
}
  • Первый вход адреса создаёт его аккаунт: строка пользователя вставляется со своими значениями по умолчанию (name: "" выше). Остальное спроси потом.
  • Код вводится на том устройстве, которое его запросило. Он живёт 10 минут и допускает 5 неверных попыток.
  • sendCode всегда резолвится для корректного адреса — он не говорит, существует ли аккаунт.
  • Под lecodes dev и при других локальных запусках ничего не отправляется: код печатается в терминале, а 000000 впускает любой адрес.
статус сообщение когда
429 Try again in 30 s второй код на адрес в течение 30 секунд
429 Too many codes requested — try again later больше 3 за 10 минут
400 Code expired — request a new one с этого устройства код не запрашивался, или он старше 10 минут
400 Invalid code
429 Too many attempts — request a new code 5 неверных попыток

Свой способ входа

Пароли и коды на email проверяет платформа. Всё остальное — инвайт, init data Telegram mini-app, корпоративный single sign-on, провайдер вроде Google — проверяет твой собственный серверный код, а затем говорит, кто это устройство:

TypeScript
await db.auth.signIn(userId)       // это устройство вошло как этот пользователь; 404, когда такой строки нет
TypeScript
const Invite = model({
  code: t.string().unique(),
  user: t.one("User").onDelete("cascade"),
})

export async function signInWithInvite(code: string): Promise<Me> {
  const invite = await db.invite.where({ code }).select({ user: { id: true } }).first()
  if (!invite) throw new ApiError(401, "Unknown invite")
  await db.auth.signIn(invite.user.id)
  return db.auth.requireUser().select({ id: true, name: true })
}
  • Строку пользователя создаёшь ты сам: db.user.insert({ name }).
  • signIn доверяет тебе: проверь человека ДО вызова. Кто может заставить этот эндпоинт вызвать его с чьим-то id, тот и есть этот кто-то.
  • У такого аккаунта нет пароля, и setPassword ему отказывает (400): ему не к чему привязать пароль.
Not implemented

встроенного «Войти через Google / Apple / Yandex / VK» нет. Проект, которому он нужен, сам проходит flow провайдера и завершает его через db.auth.signIn(userId).

Гости

У каждого устройства есть сессия с первого вызова, до любого входа. db.auth.sessionId — её id, и модель может на неё указывать — данные устройства, а не аккаунта:

TypeScript
const CartItem = model({
  product: t.string(),
  session: t.one("Session").onDelete("cascade"),
})
const db = defineDb({ User, CartItem }).withAuth({ model: "User" })

export async function addToCart(product: string) {
  await db.cartItem.insert({ product, session: { id: db.auth.sessionId } })
}

export async function cart() {
  return db.cartItem.where({ session: { id: db.auth.sessionId } }).select({ id: true, product: true })
}

Сессия остаётся той же при входе и выходе, так что корзина на месте и после того, и после другого.

Устройства аккаунта и его логин

TypeScript
db.auth.sessions       // запрос: устройства вошедшего пользователя — { id, createdAt, lastSeenAt, revoked }
db.auth.identities     // запрос: чем пользователь входит — { provider, subject, verifiedAt, createdAt }
TypeScript
export async function devices() {
  return db.auth.sessions
}

export async function signOutOtherDevices() {
  return db.auth.sessions.where({ id: { $ne: db.auth.sessionId } }).updateMany({ revoked: true })   // сколько
}

export async function signsInBy() {
  const [way] = await db.auth.identities.select({ provider: true, subject: true })
  return way ?? null
  // { provider: "login", subject: "ann" }      provider: "email" | "login"
}
  • Отозванное устройство при следующем вызове — гость, с новой сессией: то, что указывало на старую, больше не его.
  • Пользователь входит одним способом: email или логин регистрации, либо email первого кода. identities отвечает этой одной строкой — или ни одной, для аккаунта, который впустил твой собственный код. verifiedAt проставляется, когда код подтвердил адрес.

Сессия не хранит ни адреса, ни имени устройства. То, что приложение хочет помнить о входе — откуда он, когда — это твоя модель, указывающая на сессию:

TypeScript
const SignIn = model({
  user: t.one("User").onDelete("cascade"),
  session: t.one("Session").onDelete("cascade"),
  ip: t.string().optional(),
  at: t.date().default("now"),
})

export async function login(login: string, password: string) {
  await db.auth.signInWithPassword(login, password)
  const user = await db.auth.requireUser().select({ id: true })
  await db.signIn.insert({ user, session: { id: db.auth.sessionId }, ip: request.ip })
}

Адрес — персональные данные: храни его, только если приложению он нужен, и скажи об этом пользователям.

Чтобы удалить аккаунт, удали строку пользователя. Записи платформы об этом пользователе уходят вместе с ней, его устройства становятся гостями, а твои модели следуют своему onDelete:

TypeScript
export async function deleteAccount() {
  const user = await db.auth.requireUser().select({ id: true })
  await db.user.delete(user.id)
}

В приложении

  • Приложению нечем управлять. Сессия сама едет с каждым вызовом и каждым каналом и хранится на устройстве между запусками.
  • После входа или выхода подписки приложения на каналы сами запрашиваются заново для нового пользователя.
  • ApiError сервера приходят как RpcError: показывай e.message при 4xx, 401 на любом вызове считай «разлогинен» (сессия отозвана с другого устройства).

Подводные камни

TypeScript
db.auth.requireUser()                                         // ✗ ошибка компиляции: без await, ничего не проверяет
await db.auth.requireUser().select({ id: true })              // ✓

export async function myNotes(userId: number) { … }           // ✗ приложение может прислать чей угодно id
const user = await db.auth.requireUser().select({ id: true }) // ✓ сервер знает, кто вызывает

const User = model({ name: t.string() })                      // ✗ withAuth бросает: User.name needs .optional() or .default(...)
const User = model({ name: t.string().default("") })          // ✓

const db = defineDb({ User }); db.withAuth({ model: "User" }) // ✗ ошибка типа на `db.auth`: он есть только у того, что отвечает withAuth
const db = defineDb({ User }).withAuth({ model: "User" })     // ✓

Смотрите также