mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fb6c33e49 |
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 ------------------------------------------------------
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user