Skip to content

Commit

Permalink
✨ Allow appeals on takendown account (#3251)
Browse files Browse the repository at this point in the history
* ✨ Allow appeals on takendown account

* ✅ Update snapshot

* ✅ Remove duplicate test

* ✨ Respond with takendown token from createSession for takendown accounts

* 🧹 cleanup appeal account action stuff

* 📝 Add description to new field

* ♻️ Refactor authscope formatter and add test for create record with takendown token

* ✅ Update snapshot

* add createReport route

* changeset

---------

Co-authored-by: dholms <[email protected]>
  • Loading branch information
foysalit and dholms authored Dec 20, 2024
1 parent 522294b commit 6d308b8
Show file tree
Hide file tree
Showing 23 changed files with 354 additions and 29 deletions.
5 changes: 5 additions & 0 deletions .changeset/kind-meals-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/pds": patch
---

Allow takendown account scope on access tokens. Allow takendown accounts to createReports at discretion of the moderation service
5 changes: 5 additions & 0 deletions .changeset/large-laws-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/api": patch
---

Allow createSession to request takendown account scope
6 changes: 5 additions & 1 deletion lexicons/com/atproto/server/createSession.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
"description": "Handle or other identifier supported by the server for the authenticating user."
},
"password": { "type": "string" },
"authFactorToken": { "type": "string" }
"authFactorToken": { "type": "string" },
"allowTakendown": {
"type": "boolean",
"description": "When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned"
}
}
}
},
Expand Down
5 changes: 5 additions & 0 deletions packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,11 @@ export const schemaDict = {
authFactorToken: {
type: 'string',
},
allowTakendown: {
type: 'boolean',
description:
'When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned',
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface InputSchema {
identifier: string
password: string
authFactorToken?: string
/** When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned */
allowTakendown?: boolean
[k: string]: unknown
}

Expand Down
5 changes: 5 additions & 0 deletions packages/bsky/src/lexicon/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,11 @@ export const schemaDict = {
authFactorToken: {
type: 'string',
},
allowTakendown: {
type: 'boolean',
description:
'When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned',
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface InputSchema {
identifier: string
password: string
authFactorToken?: string
/** When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned */
allowTakendown?: boolean
[k: string]: unknown
}

Expand Down
43 changes: 42 additions & 1 deletion packages/ozone/src/api/report/createReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import { Server } from '../../lexicon'
import AppContext from '../../context'
import { getReasonType } from '../util'
import { subjectFromInput } from '../../mod-service/subject'
import { REASONAPPEAL } from '../../lexicon/types/com/atproto/moderation/defs'
import {
REASONAPPEAL,
ReasonType,
} from '../../lexicon/types/com/atproto/moderation/defs'
import { ForbiddenError } from '@atproto/xrpc-server'
import { TagService } from '../../tag-service'
import { ModerationService } from '../../mod-service'
import { getTagForReport } from '../../tag-service/util'

export default function (server: Server, ctx: AppContext) {
Expand All @@ -22,6 +26,9 @@ export default function (server: Server, ctx: AppContext) {
}

const db = ctx.db

await assertValidReporter(ctx.modService(db), reasonType, requester)

const report = await db.transaction(async (dbTxn) => {
const moderationTxn = ctx.modService(dbTxn)
const { event: reportEvent, subjectStatus } =
Expand Down Expand Up @@ -51,3 +58,37 @@ export default function (server: Server, ctx: AppContext) {
},
})
}

const assertValidReporter = async (
modService: ModerationService,
reasonType: ReasonType,
did: string,
) => {
const reporterStatus = await modService.getCurrentStatus({ did })

// If we don't have a mod status for the reporter, no need to do further checks
if (!reporterStatus.length) {
return
}

// For appeals, we just need to make sure that the account does not have pending appeal
if (reasonType === REASONAPPEAL) {
if (reporterStatus[0]?.appealed) {
throw new ForbiddenError(
'Awaiting decision on previous appeal',
'AlreadyAppealed',
)
}
return
}

// For non appeals, we need to make sure the reporter account is not already in takendown status
// This is necessary because we allow takendown accounts call createReport but that's only meant for appeals
// and we need to make sure takendown accounts don't abuse this endpoint
if (reporterStatus[0]?.takendown) {
throw new ForbiddenError(
'Report not accepted from takendown account',
'AccountTakedown',
)
}
}
5 changes: 5 additions & 0 deletions packages/ozone/src/lexicon/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,11 @@ export const schemaDict = {
authFactorToken: {
type: 'string',
},
allowTakendown: {
type: 'boolean',
description:
'When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned',
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface InputSchema {
identifier: string
password: string
authFactorToken?: string
/** When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned */
allowTakendown?: boolean
[k: string]: unknown
}

Expand Down
6 changes: 5 additions & 1 deletion packages/pds/src/account-manager/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,11 @@ export const getRefreshTokenId = () => {
return ui8.toString(crypto.randomBytes(32), 'base64')
}

export const formatScope = (appPassword: AppPassDescript | null): AuthScope => {
export const formatScope = (
appPassword: AppPassDescript | null,
isSoftDeleted?: boolean,
): AuthScope => {
if (isSoftDeleted) return AuthScope.Takendown
if (!appPassword) return AuthScope.Access
return appPassword.privileged
? AuthScope.AppPassPrivileged
Expand Down
20 changes: 9 additions & 11 deletions packages/pds/src/account-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,15 +211,19 @@ export class AccountManager
async createSession(
did: string,
appPassword: password.AppPassDescript | null,
isSoftDeleted = false,
) {
const { accessJwt, refreshJwt } = await auth.createTokens({
did,
jwtKey: this.jwtKey,
serviceDid: this.serviceDid,
scope: auth.formatScope(appPassword),
scope: auth.formatScope(appPassword, isSoftDeleted),
})
const refreshPayload = auth.decodeRefreshToken(refreshJwt)
await auth.storeRefreshToken(this.db, refreshPayload, appPassword)
// For soft deleted accounts don't store refresh token so that it can't be rotated.
if (!isSoftDeleted) {
const refreshPayload = auth.decodeRefreshToken(refreshJwt)
await auth.storeRefreshToken(this.db, refreshPayload, appPassword)
}
return { accessJwt, refreshJwt }
}

Expand Down Expand Up @@ -295,6 +299,7 @@ export class AccountManager
}): Promise<{
user: ActorAccount
appPassword: password.AppPassDescript | null
isSoftDeleted: boolean
}> {
const start = Date.now()
try {
Expand Down Expand Up @@ -326,14 +331,7 @@ export class AccountManager
}
}

if (softDeleted(user)) {
throw new AuthRequiredError(
'Account has been taken down',
'AccountTakedown',
)
}

return { user, appPassword }
return { user, appPassword, isSoftDeleted: softDeleted(user) }
} finally {
// Mitigate timing attacks
await wait(350 - (Date.now() - start))
Expand Down
2 changes: 2 additions & 0 deletions packages/pds/src/api/com/atproto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AppContext from '../../../context'
import { Server } from '../../../lexicon'
import admin from './admin'
import identity from './identity'
import moderation from './moderation'
import repo from './repo'
import serverMethods from './server'
import sync from './sync'
Expand All @@ -10,6 +11,7 @@ import temp from './temp'
export default function (server: Server, ctx: AppContext) {
admin(server, ctx)
identity(server, ctx)
moderation(server, ctx)
repo(server, ctx)
serverMethods(server, ctx)
sync(server, ctx)
Expand Down
36 changes: 36 additions & 0 deletions packages/pds/src/api/com/atproto/moderation/createReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { parseProxyInfo } from '../../../../pipethrough'
import { ids } from '../../../../lexicon/lexicons'
import { AtpAgent } from '@atproto/api'
import { AuthScope } from '../../../../auth-verifier'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.moderation.createReport({
auth: ctx.authVerifier.accessStandard({
additional: [AuthScope.Takendown],
}),
handler: async ({ auth, input, req }) => {
const { url, did: aud } = await parseProxyInfo(
ctx,
req,
ids.ComAtprotoModerationCreateReport,
)
const agent = new AtpAgent({ service: url })
const serviceAuth = await ctx.serviceAuthHeaders(
auth.credentials.did,
aud,
ids.ComAtprotoModerationCreateReport,
)
const res = await agent.com.atproto.moderation.createReport(input.body, {
...serviceAuth,
encoding: 'application/json',
})

return {
encoding: 'application/json',
body: res.data,
}
},
})
}
7 changes: 7 additions & 0 deletions packages/pds/src/api/com/atproto/moderation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import AppContext from '../../../../context'
import { Server } from '../../../../lexicon'
import createReport from './createReport'

export default function (server: Server, ctx: AppContext) {
createReport(server, ctx)
}
13 changes: 11 additions & 2 deletions packages/pds/src/api/com/atproto/server/createSession.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DAY, MINUTE } from '@atproto/common'
import { INVALID_HANDLE } from '@atproto/syntax'
import { AuthRequiredError } from '@atproto/xrpc-server'

import { formatAccountStatus } from '../../../../account-manager'
import AppContext from '../../../../context'
Expand Down Expand Up @@ -31,10 +32,18 @@ export default function (server: Server, ctx: AppContext) {
)
}

const { user, appPassword } = await ctx.accountManager.login(input.body)
const { user, isSoftDeleted, appPassword } =
await ctx.accountManager.login(input.body)

if (!input.body.allowTakendown && isSoftDeleted) {
throw new AuthRequiredError(
'Account has been taken down',
'AccountTakedown',
)
}

const [{ accessJwt, refreshJwt }, didDoc] = await Promise.all([
ctx.accountManager.createSession(user.did, appPassword),
ctx.accountManager.createSession(user.did, appPassword, isSoftDeleted),
didDocForSession(ctx, user.did),
])

Expand Down
1 change: 1 addition & 0 deletions packages/pds/src/auth-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export enum AuthScope {
AppPass = 'com.atproto.appPass',
AppPassPrivileged = 'com.atproto.appPassPrivileged',
SignupQueued = 'com.atproto.signupQueued',
Takendown = 'com.atproto.takendown',
}

export type AccessOpts = {
Expand Down
5 changes: 5 additions & 0 deletions packages/pds/src/lexicon/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,11 @@ export const schemaDict = {
authFactorToken: {
type: 'string',
},
allowTakendown: {
type: 'boolean',
description:
'When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned',
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface InputSchema {
identifier: string
password: string
authFactorToken?: string
/** When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned */
allowTakendown?: boolean
[k: string]: unknown
}

Expand Down
3 changes: 1 addition & 2 deletions packages/pds/src/pipethrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export const proxyHandler = (ctx: AppContext): CatchallHandler => {
const accessStandard = ctx.authVerifier.accessStandard()
return async (req, res, next) => {
// /!\ Hot path

try {
if (
req.method !== 'GET' &&
Expand Down Expand Up @@ -207,7 +206,7 @@ export async function pipethrough(
// Request setup/formatting
// -------------------

async function parseProxyInfo(
export async function parseProxyInfo(
ctx: AppContext,
req: express.Request,
lxm: string,
Expand Down
30 changes: 30 additions & 0 deletions packages/pds/tests/__snapshots__/takedown-appeal.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`appeal account takedown actor takedown allows appeal request. 1`] = `
Object {
"appealed": true,
"createdAt": "1970-01-01T00:00:00.000Z",
"hosting": Object {
"$type": "tools.ozone.moderation.defs#accountHosting",
"status": "unknown",
},
"id": 1,
"lastAppealedAt": "1970-01-01T00:00:00.000Z",
"lastReportedAt": "1970-01-01T00:00:00.000Z",
"lastReviewedAt": "1970-01-01T00:00:00.000Z",
"lastReviewedBy": "user(0)",
"reviewState": "tools.ozone.moderation.defs#reviewEscalated",
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(1)",
},
"subjectBlobCids": Array [],
"subjectRepoHandle": "jeff.test",
"tags": Array [
"lang:und",
"report:appeal",
],
"takendown": true,
"updatedAt": "1970-01-01T00:00:00.000Z",
}
`;
Loading

0 comments on commit 6d308b8

Please sign in to comment.