Compare commits

...
Author SHA1 Message Date
ProgrammerIn-wonderland 8fb6c33e49 Resolve PUT-2059 2026-10-16 01:40:59 -04:00
3 changed files with 112 additions and 23 deletions

No files matched your search

@@ -6,6 +6,7 @@ import { GetObjectCommand } from '@aws-sdk/client-s3';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { hash as bcryptHash } from 'bcrypt';
import { TOTP } from 'otpauth';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import type { RouteDescriptor } from '../../core/http/types.js';
import { PuterServer } from '../../server.js';
@@ -14,6 +15,7 @@ import { makeActor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
import type { WebDAVController } from './WebDAVController.js';
let server: PuterServer;
@@ -670,6 +672,92 @@ describe('WebDAVController', () => {
});
});
describe('Basic-auth account parity with /login', () => {
const makeBasicUser = async (
prefix: string,
fields: Record<string, unknown>,
) => {
const username = `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: await bcryptHash('correct-horse', 4),
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await server.stores.user.update(created.id, fields);
return { id: created.id, username };
};
const propfindAs = async (username: string, password: string) => {
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'PROPFIND',
headers: { authorization: basicAuth(username, password) },
}),
res,
noop,
);
return captured.statusCode;
};
const nowSeconds = () => Math.floor(Date.now() / 1000);
it('rejects an expired temporary password with 401', async () => {
const { username } = await makeBasicUser('webdav-temp-expired', {
temp_password_expires_at: nowSeconds() - 60,
});
expect(await propfindAs(username, 'correct-horse')).toBe(401);
});
it('rejects a seat that has not replaced its temporary password with 403', async () => {
const { username } = await makeBasicUser('webdav-temp-live', {
temp_password_expires_at: nowSeconds() + 3600,
requires_password_change: 1,
});
expect(await propfindAs(username, 'correct-horse')).toBe(403);
});
it('lets a live temporary password through once the change flag is clear', async () => {
const { username } = await makeBasicUser('webdav-temp-ok', {
temp_password_expires_at: nowSeconds() + 3600,
requires_password_change: 0,
});
const status = await propfindAs(username, 'correct-horse');
expect([401, 403]).not.toContain(status);
});
it('lets a 2FA member of a team that requires 2FA through', async () => {
const { id, username } = await makeBasicUser('webdav-2fa', {});
const secret = otpCreateSecret(username).secret;
await server.stores.user.update(id, {
otp_enabled: 1,
otp_secret: secret,
});
const seat = vi
.spyOn(server.stores.team, 'getOrgSeat')
.mockResolvedValue({ require_2fa: 1 } as never);
try {
const code = new TOTP({
issuer: 'puter.com',
label: username,
algorithm: 'SHA1',
digits: 6,
secret,
}).generate();
const status = await propfindAs(
username,
`correct-horse${code}`,
);
expect([401, 403]).not.toContain(status);
} finally {
seat.mockRestore();
}
});
});
describe('unsupported methods', () => {
it('returns 405 for unknown HTTP methods', async () => {
const { res, captured } = makeRes();
@@ -21,7 +21,7 @@ import { compare as bcryptCompare } from 'bcrypt';
import type { Request, Response } from 'express';
import { posix as pathPosix } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { makeActor, type Actor } from '../../core/actor.js';
import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import {
assertNotSuspended,
@@ -81,6 +81,7 @@ import {
peekRateLimit,
} from '../../core/http/middleware/rateLimit.js';
import { assertActorHasCredits } from '../../services/metering/enforcement.js';
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
import type { ResolvedShare } from '../../services/share/ShareService.js';
const DAV_HEADERS = {
@@ -475,28 +476,19 @@ export class WebDAVController extends PuterController {
return null;
}
// Build a session-less actor for the user
return makeActor({
user: {
id: user.id,
uuid: user.uuid,
username: user.username,
email: user.email ?? null,
suspended: user.suspended ?? false,
email_confirmed: user.email_confirmed ?? false,
requires_email_confirmation:
user.requires_email_confirmation ?? false,
// Carry the signup-time verification flags so the
// assertVerifiedAccount() gate in #dispatch can see them on
// the Basic-auth path too (the cookie / `-token` paths get
// them from AuthService#actorUserFromRow). Omitting these is
// what let phone/card-gated accounts through WebDAV.
requires_phone_verification:
user.requires_phone_verification ?? false,
requires_card_verification:
user.requires_card_verification ?? false,
},
});
// An administrator-issued temporary password that was never replaced
// dies here as it does at /login, rather than living on over DAV.
if (isTemporaryPasswordExpired(user)) {
res.status(401)
.set('WWW-Authenticate', 'Basic realm="WebDAV"')
.send('Temporary password expired');
return null;
}
// The same row→actor mapping the cookie / `-token` paths use, so the
// gates in #context see every flag (requires_password_change,
// otp_enabled, the verification flags) rather than a hand-picked few.
return this.services.auth.buildPasswordActor(user);
}
// -- OPTIONS ------------------------------------------------------
+9
View File
@@ -2758,6 +2758,15 @@ export class AuthService extends PuterService {
};
}
/**
* A session-less actor for a user whose password was verified outside a
* token (WebDAV Basic auth). Same row→actor mapping as the token paths, so
* the account gates see every flag they read.
*/
buildPasswordActor(user: UserRow): Actor {
return this.#buildUserActor(user, null);
}
#buildUserActor(user: UserRow, session: SessionRow | null): Actor {
return makeActor({
user: this.#actorUserFromRow(user),