feat: add api for authentication

This commit is contained in:
2026-08-18 16:11:51 +07:00
parent b7c9b46ca5
commit 39846e59b9
11 changed files with 485 additions and 2 deletions
+3
View File
@@ -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
+3
View File
@@ -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
+1
View File
@@ -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. |
+8
View File
@@ -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).
+110
View File
@@ -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
View File
@@ -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);
}
}
+2 -2
View File
@@ -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++) {
+5
View File
@@ -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 || "/";
+1
View File
@@ -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;
+214
View File
@@ -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");
});
});
+1
View File
@@ -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",