Вход
Аккаунты для приложения с бэкендом. Ты называешь одну модель базы данных
пользователем; платформа хранит пароли, коды из писем и устройства и сообщает каждому
эндпоинту, кто его вызывает. Всё это серверный код: db.auth. Приложение
лишь вызывает твои эндпоинты — оно никогда не видит ни токена, ни хеша пароля.
Обзор
// 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 })
}// 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)Включение
const db = defineDb({ User, Note }).withAuth({ model: "User" })withAuth— часть определения: пиши его на самомdefineDb(...).- Модель пользователя — обычная твоя модель: добавь то, что приложение знает о человеке — имя,
роль, аватар. Два правила:
- каждое поле
.optional()или имеет.default(): платформа вставляет строку при первом входе человека и не знает, что в неё положить; - она сохраняет встроенный
id.
- каждое поле
- В строке пользователя нет ничего секретного. Логины, email и хеши паролей платформа хранит отдельно от твоих моделей.
Кто вызывает
db.auth.user() // строка вошедшего пользователя, или null для гостя
db.auth.requireUser() // строка; гость → вызов отклоняется с 401 "Sign in required"
db.auth.requireUser(filter) // строка; пользователь, не подходящий под фильтр → 403 "Forbidden"
.select(shape) // какие поля читать, как в запросе; без него: id и простые поляЭто запросы к твоей модели пользователя, для пользователя этого запроса. Читай то, что нужно эндпоинту:
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 модели пользователя:
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 })
}эти запросы нужно ждать через await (или возвращать). Голый db.auth.requireUser() ничего не
выполняет и потому ничего не защищает — поэтому он и не компилируется: the user of a request is read with await — a query that is not awaited checks nothing.
Правило для данных, которые кому-то принадлежат: бери пользователя из db.auth, никогда из
аргумента. Id, который присылает приложение, может быть чьим угодно.
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")
}Пароли
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-значный код, человек его вводит.
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 — проверяет твой собственный серверный код, а затем говорит, кто это устройство:
await db.auth.signIn(userId) // это устройство вошло как этот пользователь; 404, когда такой строки нет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): ему не к чему привязать пароль.
встроенного «Войти через Google / Apple / Yandex / VK» нет. Проект, которому он нужен, сам
проходит flow провайдера и завершает его через db.auth.signIn(userId).
Гости
У каждого устройства есть сессия с первого вызова, до любого входа. db.auth.sessionId — её
id, и модель может на неё указывать — данные устройства, а не аккаунта:
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 })
}Сессия остаётся той же при входе и выходе, так что корзина на месте и после того, и после другого.
Устройства аккаунта и его логин
db.auth.sessions // запрос: устройства вошедшего пользователя — { id, createdAt, lastSeenAt, revoked }
db.auth.identities // запрос: чем пользователь входит — { provider, subject, verifiedAt, createdAt }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проставляется, когда код подтвердил адрес.
Сессия не хранит ни адреса, ни имени устройства. То, что приложение хочет помнить о входе — откуда он, когда — это твоя модель, указывающая на сессию:
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:
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 на любом вызове считай «разлогинен» (сессия отозвана с другого устройства).
Подводные камни
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" }) // ✓Смотрите также
- База данных — модели и запросы
- Серверные функции — эндпоинты, каналы, где работает бэкенд