From c324415a5e99166bd98bbb0eb4942a086331ba91 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 19 Feb 2024 16:56:03 +0100 Subject: [PATCH] wip(pds): add device & account stores --- .../db/schema/device-account.ts | 15 +++ .../src/account-manager/db/schema/device.ts | 15 +++ .../src/account-manager/db/schema/index.ts | 6 + .../src/account-manager/helpers/account.ts | 2 +- packages/pds/src/account-manager/index.ts | 63 ++++++++- .../api/com/atproto/server/createSession.ts | 97 +++++-------- packages/pds/src/context.ts | 1 + .../stores/oauth-account-store.ts | 127 +++++++++++++++++- .../stores/oauth-request-store.ts | 14 -- .../stores/oauth-session-store.ts | 45 ++++++- 10 files changed, 291 insertions(+), 94 deletions(-) create mode 100644 packages/pds/src/account-manager/db/schema/device-account.ts create mode 100644 packages/pds/src/account-manager/db/schema/device.ts diff --git a/packages/pds/src/account-manager/db/schema/device-account.ts b/packages/pds/src/account-manager/db/schema/device-account.ts new file mode 100644 index 00000000000..d535bff5fd5 --- /dev/null +++ b/packages/pds/src/account-manager/db/schema/device-account.ts @@ -0,0 +1,15 @@ +import { Selectable } from 'kysely' + +export interface DeviceAccount { + did: string + device_id: string + authTime: string + remember: boolean + trustedClients: string +} + +export type DeviceAccountEntry = Selectable + +export const tableName = 'device_account' + +export type PartialDB = { [tableName]: DeviceAccount } diff --git a/packages/pds/src/account-manager/db/schema/device.ts b/packages/pds/src/account-manager/db/schema/device.ts new file mode 100644 index 00000000000..2ab273f8d0f --- /dev/null +++ b/packages/pds/src/account-manager/db/schema/device.ts @@ -0,0 +1,15 @@ +import { Selectable } from 'kysely' + +export interface Device { + id: string // Index this + sessionId: `ses-${string}` // Index this + userAgent: string | null + ipAddress: string + lastSeenAt: string +} + +export type DeviceEntry = Selectable + +export const tableName = 'device' + +export type PartialDB = { [tableName]: Device } diff --git a/packages/pds/src/account-manager/db/schema/index.ts b/packages/pds/src/account-manager/db/schema/index.ts index 6bcd95a9138..179f76ce7b9 100644 --- a/packages/pds/src/account-manager/db/schema/index.ts +++ b/packages/pds/src/account-manager/db/schema/index.ts @@ -1,5 +1,7 @@ import * as actor from './actor' import * as account from './account' +import * as device from './device' +import * as deviceAccount from './device-account' import * as repoRoot from './repo-root' import * as refreshToken from './refresh-token' import * as appPassword from './app-password' @@ -8,6 +10,8 @@ import * as emailToken from './email-token' export type DatabaseSchema = actor.PartialDB & account.PartialDB & + device.PartialDB & + deviceAccount.PartialDB & refreshToken.PartialDB & appPassword.PartialDB & repoRoot.PartialDB & @@ -16,6 +20,8 @@ export type DatabaseSchema = actor.PartialDB & export type { Actor, ActorEntry } from './actor' export type { Account, AccountEntry } from './account' +export type { Device, DeviceEntry } from './device' +export type { DeviceAccount, DeviceAccountEntry } from './device-account' export type { RepoRoot } from './repo-root' export type { RefreshToken } from './refresh-token' export type { AppPassword } from './app-password' diff --git a/packages/pds/src/account-manager/helpers/account.ts b/packages/pds/src/account-manager/helpers/account.ts index f0e87c6d0ed..71750535b6e 100644 --- a/packages/pds/src/account-manager/helpers/account.ts +++ b/packages/pds/src/account-manager/helpers/account.ts @@ -10,7 +10,7 @@ export type ActorAccount = ActorEntry & { invitesDisabled: 0 | 1 | null } -const selectAccountQB = (db: AccountDb, includeSoftDeleted: boolean) => { +export const selectAccountQB = (db: AccountDb, includeSoftDeleted: boolean) => { const { ref } = db.db.dynamic return db.db .selectFrom('actor') diff --git a/packages/pds/src/account-manager/index.ts b/packages/pds/src/account-manager/index.ts index 469d942aea9..33bd2461999 100644 --- a/packages/pds/src/account-manager/index.ts +++ b/packages/pds/src/account-manager/index.ts @@ -1,17 +1,20 @@ import { KeyObject } from 'node:crypto' -import { HOUR } from '@atproto/common' +import { HOUR, wait } from '@atproto/common' +import { AuthRequiredError } from '@atproto/xrpc-server' import { CID } from 'multiformats/cid' + +import { AuthScope } from '../auth-verifier' +import { softDeleted } from '../db' +import { StatusAttr } from '../lexicon/types/com/atproto/admin/defs' import { AccountDb, EmailTokenPurpose, getDb, getMigrator } from './db' -import * as scrypt from './helpers/scrypt' import * as account from './helpers/account' import { ActorAccount } from './helpers/account' -import * as repo from './helpers/repo' import * as auth from './helpers/auth' +import * as emailToken from './helpers/email-token' import * as invite from './helpers/invite' import * as password from './helpers/password' -import * as emailToken from './helpers/email-token' -import { AuthScope } from '../auth-verifier' -import { StatusAttr } from '../lexicon/types/com/atproto/admin/defs' +import * as repo from './helpers/repo' +import * as scrypt from './helpers/scrypt' export class AccountManager { db: AccountDb @@ -20,6 +23,7 @@ export class AccountManager { dbLocation: string, private jwtKey: KeyObject, private serviceDid: string, + readonly publicUrl: string, disableWalAutoCheckpoint = false, ) { this.db = getDb(dbLocation, disableWalAutoCheckpoint) @@ -211,6 +215,53 @@ export class AccountManager { return auth.revokeRefreshToken(this.db, id) } + // Login + // ---------- + + async login( + { identifier, password }: { identifier: string; password: string }, + allowAppPassword = false, + ): Promise<{ + user: ActorAccount + appPasswordName: string | null + }> { + const start = Date.now() + try { + const identifierNormalized = identifier.toLowerCase() + const user = identifier.includes('@') + ? await this.getAccountByEmail(identifierNormalized, true) + : await this.getAccount(identifierNormalized, true) + + if (!user) throw new AuthRequiredError('Invalid identifier or password') + + let appPasswordName: string | null = null + const validAccountPass = await this.verifyAccountPassword( + user.did, + password, + ) + if (!validAccountPass) { + if (allowAppPassword) { + appPasswordName = await this.verifyAppPassword(user.did, password) + } + if (appPasswordName === null) { + throw new AuthRequiredError('Invalid identifier or password') + } + } + + if (softDeleted(user)) { + throw new AuthRequiredError( + 'Account has been taken down', + 'AccountTakedown', + ) + } + + return { user, appPasswordName } + } finally { + // Mitigate timing attacks + await wait(350 - (Date.now() - start)) + } + } + // Passwords // ---------- diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index 70e42fdde62..0c23ffd26e4 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -1,13 +1,14 @@ import { DAY, MINUTE } from '@atproto/common' import { INVALID_HANDLE } from '@atproto/syntax' -import { AuthRequiredError } from '@atproto/xrpc-server' + import AppContext from '../../../../context' -import { softDeleted } from '../../../../db/util' import { Server } from '../../../../lexicon' -import { didDocForSession } from './util' import { authPassthru, resultPassthru } from '../../../proxy' +import { didDocForSession } from './util' export default function (server: Server, ctx: AppContext) { + const { entrywayAgent } = ctx + server.com.atproto.server.createSession({ rateLimit: [ { @@ -21,66 +22,38 @@ export default function (server: Server, ctx: AppContext) { calcKey: ({ input, req }) => `${input.body.identifier}-${req.ip}`, }, ], - handler: async ({ input, req }) => { - if (ctx.entrywayAgent) { - return resultPassthru( - await ctx.entrywayAgent.com.atproto.server.createSession( - input.body, - authPassthru(req, true), - ), - ) - } - - const { password } = input.body - const identifier = input.body.identifier.toLowerCase() - - const user = identifier.includes('@') - ? await ctx.accountManager.getAccountByEmail(identifier, true) - : await ctx.accountManager.getAccount(identifier, true) - - if (!user) { - throw new AuthRequiredError('Invalid identifier or password') - } - - let appPasswordName: string | null = null - const validAccountPass = await ctx.accountManager.verifyAccountPassword( - user.did, - password, - ) - if (!validAccountPass) { - appPasswordName = await ctx.accountManager.verifyAppPassword( - user.did, - password, - ) - if (appPasswordName === null) { - throw new AuthRequiredError('Invalid identifier or password') + handler: entrywayAgent + ? async ({ input, req }) => { + return resultPassthru( + await entrywayAgent.com.atproto.server.createSession( + input.body, + authPassthru(req, true), + ), + ) } - } - - if (softDeleted(user)) { - throw new AuthRequiredError( - 'Account has been taken down', - 'AccountTakedown', - ) - } - - const [{ accessJwt, refreshJwt }, didDoc] = await Promise.all([ - ctx.accountManager.createSession(user.did, appPasswordName), - didDocForSession(ctx, user.did), - ]) - - return { - encoding: 'application/json', - body: { - did: user.did, - didDoc, - handle: user.handle ?? INVALID_HANDLE, - email: user.email ?? undefined, - emailConfirmed: !!user.emailConfirmedAt, - accessJwt, - refreshJwt, + : async ({ input, req }) => { + const { user, appPasswordName } = await ctx.accountManager.login( + input.body, + true, + ) + + const [{ accessJwt, refreshJwt }, didDoc] = await Promise.all([ + ctx.accountManager.createSession(user.did, appPasswordName), + didDocForSession(ctx, user.did), + ]) + + return { + encoding: 'application/json', + body: { + did: user.did, + didDoc, + handle: user.handle ?? INVALID_HANDLE, + email: user.email ?? undefined, + emailConfirmed: !!user.emailConfirmedAt, + accessJwt, + refreshJwt, + }, + } }, - } - }, }) } diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 8da2e1b909e..d13c01c6eaf 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -178,6 +178,7 @@ export class AppContext { cfg.db.accountDbLoc, jwtSecretKey, cfg.service.did, + cfg.service.publicUrl, cfg.db.disableWalAutoCheckpoint, ) await accountManager.migrateOrThrow() diff --git a/packages/pds/src/oauth-provider/stores/oauth-account-store.ts b/packages/pds/src/oauth-provider/stores/oauth-account-store.ts index 1398847dd35..4a0fe2c7f9f 100644 --- a/packages/pds/src/oauth-provider/stores/oauth-account-store.ts +++ b/packages/pds/src/oauth-provider/stores/oauth-account-store.ts @@ -6,31 +6,144 @@ import type { DeviceId, Sub, } from '@atproto/oauth-provider' +import { AuthRequiredError } from '@atproto/xrpc-server' import { AccountManager } from '../../account-manager/index.js' +import { notSoftDeletedClause } from '../../db/util.js' export class OAuthAccountStore implements AccountStore { constructor(private readonly accountManager: AccountManager) {} + private get db() { + return this.accountManager.db.db + } + + async login( + { username: identifier, password }: AccountLoginCredentials, + deviceId: DeviceId | null, + ): Promise { + try { + const { user, appPasswordName } = await this.accountManager.login({ + identifier, + password, + }) + + // App password are only allowed when using the password_grant flow which + // allows clients to migrate to from the old system to the OAuth system. + if (appPasswordName != null && deviceId != null) { + return null + } + + return this.buildAccount(user) + } catch (err) { + if (err instanceof AuthRequiredError) return null + throw err + } + } + async addAccount( deviceId: DeviceId, - credentials: AccountLoginCredentials, - ): Promise<{ account: Account; info: DeviceAccountInfo } | null> { - throw new Error('Method not implemented.') + sub: Sub, + info: DeviceAccountInfo, + ): Promise { + const values = { + remember: info.remember, + authTime: info.authTime.toISOString(), + trustedClients: JSON.stringify(info.trustedClients), + } + + await this.db + .insertInto('device_account') + .values({ + did: sub, + device_id: deviceId, + ...values, + }) + .onConflict((oc) => oc.columns(['did', 'device_id']).doUpdateSet(values)) + .executeTakeFirstOrThrow() } + async listAccounts( deviceId: DeviceId, sub?: Sub, ): Promise<{ account: Account; info: DeviceAccountInfo }[]> { - throw new Error('Method not implemented.') + const { ref } = this.db.dynamic + const accounts = await this.db + .selectFrom('device') + .where('device.id', '=', deviceId) + .innerJoin('device_account', 'device_account.device_id', 'device.id') + .innerJoin('account', 'account.did', 'device_account.did') + .innerJoin('actor', 'actor.did', 'account.did') + .where(notSoftDeletedClause(ref('actor'))) + .where('device_account.remember', '=', true) + .if(sub != null, (q) => q.where('account.did', '=', sub!)) + .select([ + 'account.did', + 'account.email', + 'account.emailConfirmedAt', + 'actor.handle', + 'device_account.authTime', + 'device_account.remember', + 'device_account.trustedClients', + ]) + .execute() + + return accounts.map((row) => ({ + account: this.buildAccount(row), + info: this.buildInfo(row), + })) } - async updateAccountInfo( + + async updateAccount( deviceId: DeviceId, sub: Sub, info: Partial, ): Promise { - throw new Error('Method not implemented.') + const { remember, authTime, trustedClients } = info + + await this.db + .updateTable('device_account') + .if(remember != null, (q) => q.set({ remember })) + .if(authTime != null, (q) => q.set({ authTime: authTime!.toISOString() })) + .if(trustedClients != null, (q) => + q.set({ trustedClients: JSON.stringify(trustedClients) }), + ) + .where('did', '=', sub) + .where('device_id', '=', deviceId) + .execute() } + async removeAccount(deviceId: DeviceId, sub: Sub): Promise { - throw new Error('Method not implemented.') + await this.db + .deleteFrom('device_account') + .where('did', '=', sub) + .where('device_id', '=', deviceId) + .execute() + } + + private buildAccount(row: { + did: string + email: string | null + emailConfirmedAt: string | null + handle: string | null + }): Account { + return { + sub: row.did, + aud: this.accountManager.publicUrl, + email: row.email || undefined, + email_verified: row.email ? row.emailConfirmedAt != null : undefined, + preferred_username: row.handle || undefined, + } + } + + private buildInfo(row: { + remember: boolean + authTime: string + trustedClients: string + }): DeviceAccountInfo { + return { + remember: row.remember, + authTime: new Date(row.authTime), + trustedClients: JSON.parse(row.trustedClients) as string[], + } } } diff --git a/packages/pds/src/oauth-provider/stores/oauth-request-store.ts b/packages/pds/src/oauth-provider/stores/oauth-request-store.ts index f64af1414cd..3511b322958 100644 --- a/packages/pds/src/oauth-provider/stores/oauth-request-store.ts +++ b/packages/pds/src/oauth-provider/stores/oauth-request-store.ts @@ -1,22 +1,8 @@ import type { - Account, - AccountLoginCredentials, - AccountStore, - Sub, Code, - DeviceAccountInfo, - DeviceId, - RefreshToken, - ReplayStore, RequestData, RequestId, RequestStore, - SessionData, - SessionStore, - TokenData, - TokenId, - TokenInfo, - TokenStore, } from '@atproto/oauth-provider' import { AccountManager } from '../../account-manager/index.js' diff --git a/packages/pds/src/oauth-provider/stores/oauth-session-store.ts b/packages/pds/src/oauth-provider/stores/oauth-session-store.ts index 77c95e0c5e7..865f731affe 100644 --- a/packages/pds/src/oauth-provider/stores/oauth-session-store.ts +++ b/packages/pds/src/oauth-provider/stores/oauth-session-store.ts @@ -8,16 +8,53 @@ import { AccountManager } from '../../account-manager/index.js' export class OAuthSessionStore implements SessionStore { constructor(private readonly accountManager: AccountManager) {} + private get db() { + return this.accountManager.db.db + } + async createSession(deviceId: DeviceId, data: SessionData): Promise { - throw new Error('Method not implemented.') + await this.db + .insertInto('device') + .values({ + id: deviceId, + sessionId: data.sessionId, + userAgent: data.userAgent, + ipAddress: data.ipAddress, + lastSeenAt: data.lastSeenAt.toISOString(), + }) + .execute() } + async readSession(deviceId: DeviceId): Promise { - throw new Error('Method not implemented.') + const device = await this.db + .selectFrom('device') + .where('id', '=', deviceId) + .select(['sessionId', 'userAgent', 'ipAddress', 'lastSeenAt']) + .executeTakeFirst() + + if (device == null) return null + + return { + sessionId: device.sessionId, + userAgent: device.userAgent, + ipAddress: device.ipAddress, + lastSeenAt: new Date(device.lastSeenAt), + } } + async updateSession( deviceId: DeviceId, - data: Partial, + { sessionId, userAgent, ipAddress, lastSeenAt }: Partial, ): Promise { - throw new Error('Method not implemented.') + await this.db + .updateTable('device') + .if(sessionId != null, (qb) => qb.set({ sessionId: sessionId })) + .if(userAgent != null, (qb) => qb.set({ userAgent: userAgent })) + .if(ipAddress != null, (qb) => qb.set({ ipAddress: ipAddress })) + .if(lastSeenAt != null, (qb) => + qb.set({ lastSeenAt: lastSeenAt!.toISOString() }), + ) + .where('id', '=', deviceId) + .execute() } }