Skip to content

Commit

Permalink
wip(pds): add device & account stores
Browse files Browse the repository at this point in the history
  • Loading branch information
matthieusieben committed Feb 20, 2024
1 parent 0c062f4 commit c324415
Show file tree
Hide file tree
Showing 10 changed files with 291 additions and 94 deletions.
15 changes: 15 additions & 0 deletions packages/pds/src/account-manager/db/schema/device-account.ts
Original file line number Diff line number Diff line change
@@ -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<DeviceAccount>

export const tableName = 'device_account'

export type PartialDB = { [tableName]: DeviceAccount }
15 changes: 15 additions & 0 deletions packages/pds/src/account-manager/db/schema/device.ts
Original file line number Diff line number Diff line change
@@ -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<Device>

export const tableName = 'device'

export type PartialDB = { [tableName]: Device }
6 changes: 6 additions & 0 deletions packages/pds/src/account-manager/db/schema/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 &
Expand All @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion packages/pds/src/account-manager/helpers/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
63 changes: 57 additions & 6 deletions packages/pds/src/account-manager/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
// ----------

Expand Down
97 changes: 35 additions & 62 deletions packages/pds/src/api/com/atproto/server/createSession.ts
Original file line number Diff line number Diff line change
@@ -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: [
{
Expand All @@ -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,
},
}
},
}
},
})
}
1 change: 1 addition & 0 deletions packages/pds/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export class AppContext {
cfg.db.accountDbLoc,
jwtSecretKey,
cfg.service.did,
cfg.service.publicUrl,
cfg.db.disableWalAutoCheckpoint,
)
await accountManager.migrateOrThrow()
Expand Down
Loading

0 comments on commit c324415

Please sign in to comment.