mirror of
https://github.com/Nezumi-2711/google-drive-s3.git
synced 2026-09-22 13:38:30 +00:00
feat: add api for authentication
This commit is contained in:
@@ -17,6 +17,9 @@ ALLOWED_BUCKETS=assets
|
|||||||
# Optional comma-separated subset of ALLOWED_BUCKETS that permits unsigned GET/HEAD.
|
# Optional comma-separated subset of ALLOWED_BUCKETS that permits unsigned GET/HEAD.
|
||||||
PUBLIC_READ_BUCKETS=
|
PUBLIC_READ_BUCKETS=
|
||||||
|
|
||||||
|
# Plaintext password for dashboard management API authentication.
|
||||||
|
DASHBOARD_PASSWORD=replace-with-dashboard-password
|
||||||
|
|
||||||
# Durable Object multipart uploads and ETag result behavior.
|
# Durable Object multipart uploads and ETag result behavior.
|
||||||
ALLOW_MULTIPART=true
|
ALLOW_MULTIPART=true
|
||||||
ETAG_STYLE=md5
|
ETAG_STYLE=md5
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ ALLOWED_BUCKETS=assets
|
|||||||
# Optional comma-separated subset of ALLOWED_BUCKETS that permits unsigned GET/HEAD.
|
# Optional comma-separated subset of ALLOWED_BUCKETS that permits unsigned GET/HEAD.
|
||||||
PUBLIC_READ_BUCKETS=
|
PUBLIC_READ_BUCKETS=
|
||||||
|
|
||||||
|
# Plaintext password for dashboard management API authentication
|
||||||
|
DASHBOARD_PASSWORD=replace-with-dashboard-password
|
||||||
|
|
||||||
# Durable Object multipart uploads and ETag result behavior
|
# Durable Object multipart uploads and ETag result behavior
|
||||||
ALLOW_MULTIPART=true
|
ALLOW_MULTIPART=true
|
||||||
ETAG_STYLE=md5
|
ETAG_STYLE=md5
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ https://developers.cloudflare.com/workers/configuration/secrets/#via-the-dashboa
|
|||||||
| `SECRET_KEY` | A secure secret key used by the S3 client. |
|
| `SECRET_KEY` | A secure secret key used by the S3 client. |
|
||||||
| `REGION` | The region used by the S3 client. |
|
| `REGION` | The region used by the S3 client. |
|
||||||
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN` | Google API credentials obtained from rclone. |
|
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN` | Google API credentials obtained from rclone. |
|
||||||
|
| `DASHBOARD_PASSWORD` | *(Optional)* Plaintext password for dashboard management API authentication. |
|
||||||
| `ALLOWED_BUCKETS` | Set the buckets allowed, separated by `,`. A directory with the bucket name will be created directly under Google Drive. |
|
| `ALLOWED_BUCKETS` | Set the buckets allowed, separated by `,`. A directory with the bucket name will be created directly under Google Drive. |
|
||||||
| `PUBLIC_READ_BUCKETS` | *(Optional)* Buckets that allow unauthenticated GET/HEAD access without signature, separated by `,`. Write operations (PUT/POST/DELETE) still require authentication. Must be a subset of `ALLOWED_BUCKETS`. |
|
| `PUBLIC_READ_BUCKETS` | *(Optional)* Buckets that allow unauthenticated GET/HEAD access without signature, separated by `,`. Write operations (PUT/POST/DELETE) still require authentication. Must be a subset of `ALLOWED_BUCKETS`. |
|
||||||
| `CORS_ALLOWED_ORIGINS` | *(Optional)* Comma-separated exact browser origins, or `*`. Unset emits no CORS headers. |
|
| `CORS_ALLOWED_ORIGINS` | *(Optional)* Comma-separated exact browser origins, or `*`. Unset emits no CORS headers. |
|
||||||
|
|||||||
@@ -36,4 +36,12 @@ Payload hashes are **not** verified. Browser and BFF clients should use `x-amz-c
|
|||||||
|
|
||||||
Buckets listed in `PUBLIC_READ_BUCKETS` permit unsigned `GET` and `HEAD` requests. All write operations still require valid Signature V4 authentication. `PUBLIC_READ_BUCKETS` must be a subset of `ALLOWED_BUCKETS`.
|
Buckets listed in `PUBLIC_READ_BUCKETS` permit unsigned `GET` and `HEAD` requests. All write operations still require valid Signature V4 authentication. `PUBLIC_READ_BUCKETS` must be a subset of `ALLOWED_BUCKETS`.
|
||||||
|
|
||||||
|
## Dashboard login API
|
||||||
|
|
||||||
|
The Worker provides management API endpoints under `/auth/*` for the management dashboard (`s3-drive-storage-manage`). These endpoints **do not** use AWS Signature V4:
|
||||||
|
|
||||||
|
- `POST /auth/login` — Verifies `{ passwordHash }` against `DASHBOARD_PASSWORD` (SHA-256 compared in constant time). Returns `{ token, expiresIn }` on success (opaque 256-bit base64url token with a 12-hour TTL stored in `AUTH_KV`). Rate-limited to 5 failed attempts per IP within 15 minutes.
|
||||||
|
- `GET /auth/session` — Validates the session token supplied in `Authorization: Bearer <token>`. Returns `{ valid: true }` if valid, or `401` if expired/invalid.
|
||||||
|
- `POST /auth/logout` — Revokes the session token supplied in `Authorization: Bearer <token>`. Returns `204 No Content`.
|
||||||
|
|
||||||
For a tested signing reference, see the `signed()` helper in [`test/s3.test.ts`](../test/s3.test.ts).
|
For a tested signing reference, see the `signed()` helper in [`test/s3.test.ts`](../test/s3.test.ts).
|
||||||
|
|||||||
@@ -17,7 +17,87 @@ tags:
|
|||||||
- name: Objects
|
- name: Objects
|
||||||
- name: Buckets
|
- name: Buckets
|
||||||
- name: Multipart uploads
|
- name: Multipart uploads
|
||||||
|
- name: Dashboard Auth
|
||||||
paths:
|
paths:
|
||||||
|
/auth/login:
|
||||||
|
post:
|
||||||
|
tags: [Dashboard Auth]
|
||||||
|
operationId: authLogin
|
||||||
|
summary: Login to dashboard
|
||||||
|
description: Authenticate with SHA-256 hashed dashboard password.
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/LoginRequest' }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Login successful.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/LoginResponse' }
|
||||||
|
'400':
|
||||||
|
description: Invalid request.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'401':
|
||||||
|
description: Invalid password.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'429':
|
||||||
|
description: Too many failed attempts.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'503':
|
||||||
|
description: Dashboard authentication not configured.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
/auth/session:
|
||||||
|
get:
|
||||||
|
tags: [Dashboard Auth]
|
||||||
|
operationId: authSession
|
||||||
|
summary: Verify session token
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Session is valid.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
valid: { type: boolean, example: true }
|
||||||
|
'401':
|
||||||
|
description: Invalid or expired session.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'503':
|
||||||
|
description: Dashboard authentication not configured.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
/auth/logout:
|
||||||
|
post:
|
||||||
|
tags: [Dashboard Auth]
|
||||||
|
operationId: authLogout
|
||||||
|
summary: Logout of dashboard session
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Successfully logged out.
|
||||||
|
'503':
|
||||||
|
description: Dashboard authentication not configured.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
/{bucket}:
|
/{bucket}:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/Bucket'
|
- $ref: '#/components/parameters/Bucket'
|
||||||
@@ -236,6 +316,10 @@ components:
|
|||||||
in: header
|
in: header
|
||||||
name: Authorization
|
name: Authorization
|
||||||
description: AWS Signature Version 4 header authentication or equivalent `X-Amz-*` presigned query parameters.
|
description: AWS Signature Version 4 header authentication or equivalent `X-Amz-*` presigned query parameters.
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
description: Session token returned from `/auth/login`.
|
||||||
parameters:
|
parameters:
|
||||||
Bucket:
|
Bucket:
|
||||||
name: bucket
|
name: bucket
|
||||||
@@ -299,6 +383,32 @@ components:
|
|||||||
description: The method/path combination is unsupported.
|
description: The method/path combination is unsupported.
|
||||||
content: { application/xml: { schema: { $ref: '#/components/schemas/S3Error' } } }
|
content: { application/xml: { schema: { $ref: '#/components/schemas/S3Error' } } }
|
||||||
schemas:
|
schemas:
|
||||||
|
LoginRequest:
|
||||||
|
type: object
|
||||||
|
required: [passwordHash]
|
||||||
|
properties:
|
||||||
|
passwordHash:
|
||||||
|
type: string
|
||||||
|
description: SHA-256 hash of the dashboard password in lowercase hex.
|
||||||
|
example: ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
|
||||||
|
LoginResponse:
|
||||||
|
type: object
|
||||||
|
required: [token, expiresIn]
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: Session token.
|
||||||
|
expiresIn:
|
||||||
|
type: integer
|
||||||
|
description: Token TTL in seconds.
|
||||||
|
example: 43200
|
||||||
|
AuthError:
|
||||||
|
type: object
|
||||||
|
required: [message]
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Invalid password
|
||||||
S3Error:
|
S3Error:
|
||||||
type: object
|
type: object
|
||||||
xml: { name: Error }
|
xml: { name: Error }
|
||||||
|
|||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
import { constantTimeEqual, sha256 } from "./aws-signature";
|
||||||
|
import type { Env } from "./types";
|
||||||
|
|
||||||
|
export const AUTH_PATH_PREFIX = "auth";
|
||||||
|
const SESSION_PREFIX = "session:";
|
||||||
|
const LOGIN_FAIL_PREFIX = "login-fail:";
|
||||||
|
const SESSION_TTL_SECONDS = 12 * 60 * 60;
|
||||||
|
const MAX_FAILED_ATTEMPTS = 5;
|
||||||
|
const LOCKOUT_SECONDS = 900;
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken(): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
return btoa(String.fromCharCode(...bytes))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAuth(request: Request, env: Env, subPath: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
if (!env.DASHBOARD_PASSWORD) {
|
||||||
|
return jsonResponse({ message: "Dashboard authentication is not configured" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === "login") {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return jsonResponse({ message: "Method Not Allowed" }, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { passwordHash?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ message: "Invalid JSON body" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || typeof body.passwordHash !== "string" || !body.passwordHash) {
|
||||||
|
return jsonResponse({ message: "Missing or invalid passwordHash" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
|
||||||
|
const failKey = `${LOGIN_FAIL_PREFIX}${ip}`;
|
||||||
|
const failedAttemptsStr = await env.AUTH_KV.get(failKey);
|
||||||
|
const failedAttempts = failedAttemptsStr ? parseInt(failedAttemptsStr, 10) : 0;
|
||||||
|
|
||||||
|
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
|
||||||
|
return jsonResponse({ message: "Too many failed attempts. Please try again later." }, 429, { "Retry-After": LOCKOUT_SECONDS.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedHash = await sha256(env.DASHBOARD_PASSWORD);
|
||||||
|
const providedHash = body.passwordHash.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!constantTimeEqual(expectedHash, providedHash)) {
|
||||||
|
const nextAttempts = failedAttempts + 1;
|
||||||
|
await env.AUTH_KV.put(failKey, nextAttempts.toString(), { expirationTtl: LOCKOUT_SECONDS });
|
||||||
|
return jsonResponse({ message: "Invalid password" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login successful
|
||||||
|
await env.AUTH_KV.delete(failKey);
|
||||||
|
const token = generateToken();
|
||||||
|
const tokenHash = await sha256(token);
|
||||||
|
const sessionData = {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
await env.AUTH_KV.put(`${SESSION_PREFIX}${tokenHash}`, JSON.stringify(sessionData), {
|
||||||
|
expirationTtl: SESSION_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ token, expiresIn: SESSION_TTL_SECONDS }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === "session") {
|
||||||
|
if (request.method !== "GET") {
|
||||||
|
return jsonResponse({ message: "Method Not Allowed" }, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = request.headers.get("Authorization");
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
return jsonResponse({ message: "Session expired or invalid" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice("Bearer ".length).trim();
|
||||||
|
if (!token) {
|
||||||
|
return jsonResponse({ message: "Session expired or invalid" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = await sha256(token);
|
||||||
|
const session = await env.AUTH_KV.get(`${SESSION_PREFIX}${tokenHash}`);
|
||||||
|
if (!session) {
|
||||||
|
return jsonResponse({ message: "Session expired or invalid" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ valid: true }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === "logout") {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return jsonResponse({ message: "Method Not Allowed" }, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = request.headers.get("Authorization");
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
const token = authHeader.slice("Bearer ".length).trim();
|
||||||
|
if (token) {
|
||||||
|
const tokenHash = await sha256(token);
|
||||||
|
await env.AUTH_KV.delete(`${SESSION_PREFIX}${tokenHash}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ message: "Not Found" }, 404);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
message: "auth handler error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
method: request.method,
|
||||||
|
subPath,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return jsonResponse({ message: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ async function hmacSha256(key: string | ArrayBuffer, data: string): Promise<Arra
|
|||||||
return await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
|
return await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sha256(data: string): Promise<string> {
|
export async function sha256(data: string): Promise<string> {
|
||||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));
|
||||||
return bufToHex(hash);
|
return bufToHex(hash);
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ function parseAmzDate(datetime: string): number | null {
|
|||||||
return timestamp;
|
return timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
function constantTimeEqual(left: string, right: string): boolean {
|
export function constantTimeEqual(left: string, right: string): boolean {
|
||||||
if (left.length !== right.length) return false;
|
if (left.length !== right.length) return false;
|
||||||
let mismatch = 0;
|
let mismatch = 0;
|
||||||
for (let index = 0; index < left.length; index++) {
|
for (let index = 0; index < left.length; index++) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AUTH_PATH_PREFIX, handleAuth } from "./auth-api";
|
||||||
import { verifySignature } from "./aws-signature";
|
import { verifySignature } from "./aws-signature";
|
||||||
import { isAllowedBucket, isPublicReadBucket } from "./bucket-access";
|
import { isAllowedBucket, isPublicReadBucket } from "./bucket-access";
|
||||||
import { preflightResponse, withCors } from "./cors";
|
import { preflightResponse, withCors } from "./cors";
|
||||||
@@ -21,6 +22,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pathParts = url.pathname.split("/").filter(Boolean);
|
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||||
|
if (pathParts[0] === AUTH_PATH_PREFIX && !isAllowedBucket(AUTH_PATH_PREFIX, env)) {
|
||||||
|
return withCors(await handleAuth(request, env, pathParts.slice(1).join("/")), request, env);
|
||||||
|
}
|
||||||
|
|
||||||
const bucket = pathParts[0] || "";
|
const bucket = pathParts[0] || "";
|
||||||
const objectKey = pathParts.slice(1).join("/");
|
const objectKey = pathParts.slice(1).join("/");
|
||||||
const resource = url.pathname || "/";
|
const resource = url.pathname || "/";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Env {
|
|||||||
AUTH_KV: KVNamespace;
|
AUTH_KV: KVNamespace;
|
||||||
FOLDER_CACHE: KVNamespace;
|
FOLDER_CACHE: KVNamespace;
|
||||||
MPU: DurableObjectNamespace<import("./multipart-do").MultipartUploadDO>;
|
MPU: DurableObjectNamespace<import("./multipart-do").MultipartUploadDO>;
|
||||||
|
DASHBOARD_PASSWORD?: string;
|
||||||
ALLOWED_BUCKETS?: string;
|
ALLOWED_BUCKETS?: string;
|
||||||
PUBLIC_READ_BUCKETS?: string;
|
PUBLIC_READ_BUCKETS?: string;
|
||||||
ALLOW_MULTIPART?: string;
|
ALLOW_MULTIPART?: string;
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { sha256 } from "../src/aws-signature";
|
||||||
|
import worker from "../src/index";
|
||||||
|
import type { Env } from "../src/types";
|
||||||
|
|
||||||
|
import { env } from "cloudflare:test";
|
||||||
|
|
||||||
|
const ENV = env as unknown as Env;
|
||||||
|
const ENDPOINT = "https://s3-api.example.com";
|
||||||
|
const CTX = { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
describe("Dashboard authentication API routes", () => {
|
||||||
|
it("returns 503 when DASHBOARD_PASSWORD is not configured", async () => {
|
||||||
|
const withoutPassword = { ...ENV, DASHBOARD_PASSWORD: undefined };
|
||||||
|
const res = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ passwordHash: "any" }),
|
||||||
|
}),
|
||||||
|
withoutPassword,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
const data = (await res.json()) as { message: string };
|
||||||
|
expect(data.message).toBe("Dashboard authentication is not configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles login with invalid body or missing passwordHash", async () => {
|
||||||
|
const res1 = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "not-json",
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(res1.status).toBe(400);
|
||||||
|
|
||||||
|
const res2 = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(res2.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid password and succeeds with valid password hash", async () => {
|
||||||
|
const correctHash = await sha256("test-dashboard-password");
|
||||||
|
const wrongHash = await sha256("wrong-password");
|
||||||
|
|
||||||
|
// Wrong password
|
||||||
|
const wrongRes = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.168.1.1" },
|
||||||
|
body: JSON.stringify({ passwordHash: wrongHash }),
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(wrongRes.status).toBe(401);
|
||||||
|
const wrongData = (await wrongRes.json()) as { message: string };
|
||||||
|
expect(wrongData.message).toBe("Invalid password");
|
||||||
|
|
||||||
|
// Correct password
|
||||||
|
const correctRes = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.168.1.1" },
|
||||||
|
body: JSON.stringify({ passwordHash: correctHash.toUpperCase() }), // test case-insensitivity
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(correctRes.status).toBe(200);
|
||||||
|
const correctData = (await correctRes.json()) as { token: string; expiresIn: number };
|
||||||
|
expect(correctData.token).toBeTruthy();
|
||||||
|
expect(correctData.expiresIn).toBe(43200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies valid session and rejects invalid/expired token", async () => {
|
||||||
|
const correctHash = await sha256("test-dashboard-password");
|
||||||
|
|
||||||
|
const loginRes = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.168.1.2" },
|
||||||
|
body: JSON.stringify({ passwordHash: correctHash }),
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(loginRes.status).toBe(200);
|
||||||
|
const { token } = (await loginRes.json()) as { token: string };
|
||||||
|
|
||||||
|
// Valid session check
|
||||||
|
const validRes = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/session`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(validRes.status).toBe(200);
|
||||||
|
const validData = (await validRes.json()) as { valid: boolean };
|
||||||
|
expect(validData.valid).toBe(true);
|
||||||
|
|
||||||
|
// Invalid token
|
||||||
|
const invalidRes = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/session`, {
|
||||||
|
headers: { Authorization: "Bearer bogus-token" },
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(invalidRes.status).toBe(401);
|
||||||
|
|
||||||
|
// Missing header
|
||||||
|
const missingRes = await worker.fetch(new Request(`${ENDPOINT}/auth/session`), ENV, CTX);
|
||||||
|
expect(missingRes.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports logout and revokes session", async () => {
|
||||||
|
const correctHash = await sha256("test-dashboard-password");
|
||||||
|
|
||||||
|
const loginRes = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.168.1.3" },
|
||||||
|
body: JSON.stringify({ passwordHash: correctHash }),
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
const { token } = (await loginRes.json()) as { token: string };
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
const logoutRes = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(logoutRes.status).toBe(204);
|
||||||
|
|
||||||
|
// Session check should now fail
|
||||||
|
const sessionRes = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/session`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(sessionRes.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rate-limits after MAX_FAILED_ATTEMPTS", async () => {
|
||||||
|
const testIp = "10.0.0.99";
|
||||||
|
const wrongHash = await sha256("wrong-password");
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const res = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "CF-Connecting-IP": testIp },
|
||||||
|
body: JSON.stringify({ passwordHash: wrongHash }),
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th attempt should be blocked
|
||||||
|
const blockedRes = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "CF-Connecting-IP": testIp },
|
||||||
|
body: JSON.stringify({ passwordHash: wrongHash }),
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(blockedRes.status).toBe(429);
|
||||||
|
expect(blockedRes.headers.get("Retry-After")).toBe("900");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim /auth when auth is a configured bucket", async () => {
|
||||||
|
const withAuthBucket = { ...ENV, ALLOWED_BUCKETS: "test-bucket,auth" };
|
||||||
|
const response = await worker.fetch(new Request(`${ENDPOINT}/auth/login`), withAuthBucket, CTX);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.text()).toContain("<Code>SignatureDoesNotMatch</Code>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits CORS headers for allowed origin", async () => {
|
||||||
|
const res = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/session`, {
|
||||||
|
headers: { Origin: "http://localhost:5173" },
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:5173");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
|||||||
GOOGLE_CLIENT_ID: "test-client-id",
|
GOOGLE_CLIENT_ID: "test-client-id",
|
||||||
GOOGLE_CLIENT_SECRET: "test-client-secret",
|
GOOGLE_CLIENT_SECRET: "test-client-secret",
|
||||||
GOOGLE_REFRESH_TOKEN: "test-refresh-token",
|
GOOGLE_REFRESH_TOKEN: "test-refresh-token",
|
||||||
|
DASHBOARD_PASSWORD: "test-dashboard-password",
|
||||||
ALLOWED_BUCKETS: "test-bucket,empty-bucket,my-bucket",
|
ALLOWED_BUCKETS: "test-bucket,empty-bucket,my-bucket",
|
||||||
ALLOW_MULTIPART: "true",
|
ALLOW_MULTIPART: "true",
|
||||||
ETAG_STYLE: "md5",
|
ETAG_STYLE: "md5",
|
||||||
|
|||||||
Reference in New Issue
Block a user