mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add PublicSendPage and SendsPage components for managing sends
This commit is contained in:
@@ -15,6 +15,9 @@
|
||||
// Attachment download token lifetime in seconds.
|
||||
// 附件下载令牌有效期(秒)。
|
||||
fileDownloadTokenTtlSeconds: 300,
|
||||
// Send access token lifetime in seconds.
|
||||
// Send 访问令牌有效期(秒)。
|
||||
sendAccessTokenTtlSeconds: 300,
|
||||
// Minimum required JWT secret length.
|
||||
// JWT 密钥最小长度要求。
|
||||
jwtSecretMinLength: 32,
|
||||
@@ -73,6 +76,14 @@
|
||||
// 附件上传大小上限(字节)。
|
||||
maxFileSizeBytes: 100 * 1024 * 1024,
|
||||
},
|
||||
send: {
|
||||
// Max file size allowed for Send file uploads.
|
||||
// Send 文件上传大小上限。
|
||||
maxFileSizeBytes: 550_502_400,
|
||||
// Max days allowed between now and deletion date.
|
||||
// 允许的最远删除日期(距当前天数)。
|
||||
maxDeletionDays: 31,
|
||||
},
|
||||
pagination: {
|
||||
// Default page size when client does not specify pageSize.
|
||||
// 客户端未传 pageSize 时的默认分页大小。
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { createRefreshToken } from '../utils/jwt';
|
||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
import { issueSendAccessToken } from './sends';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
@@ -247,6 +248,40 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
return jsonResponse(response);
|
||||
|
||||
} else if (grantType === 'send_access') {
|
||||
const sendId = String(body.send_id || body.sendId || '').trim();
|
||||
if (!sendId) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'invalid_request',
|
||||
error_description: 'send_id is required',
|
||||
send_access_error_type: 'invalid_send_id',
|
||||
ErrorModel: {
|
||||
Message: 'send_id is required',
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHashB64 = String(
|
||||
body.password_hash_b64 || body.passwordHashB64 || body.passwordHash || body.password_hash || ''
|
||||
).trim() || null;
|
||||
const password = String(body.password || '').trim() || null;
|
||||
|
||||
const result = await issueSendAccessToken(env, sendId, passwordHashB64, password);
|
||||
if ('error' in result) {
|
||||
return result.error;
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
access_token: result.token,
|
||||
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
scope: 'api.send',
|
||||
unofficialServer: true,
|
||||
});
|
||||
} else if (grantType === 'refresh_token') {
|
||||
// Refresh token
|
||||
const refreshToken = body.refresh_token;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } fr
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { sendToResponse } from './sends';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled } from '../utils/totp';
|
||||
|
||||
@@ -61,6 +62,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
const sends = await storage.getAllSends(userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||
|
||||
// Build profile response
|
||||
@@ -116,7 +118,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
object: 'domains',
|
||||
},
|
||||
policies: [],
|
||||
sends: [],
|
||||
sends: sends.map(sendToResponse),
|
||||
// PascalCase for desktop/browser clients
|
||||
UserDecryptionOptions: {
|
||||
HasMasterPassword: true,
|
||||
|
||||
+86
-4
@@ -44,6 +44,25 @@ import {
|
||||
handleDeleteFolder
|
||||
} from './handlers/folders';
|
||||
|
||||
// Send handlers
|
||||
import {
|
||||
handleGetSends,
|
||||
handleGetSend,
|
||||
handleCreateSend,
|
||||
handleCreateFileSendV2,
|
||||
handleGetSendFileUpload,
|
||||
handleUploadSendFile,
|
||||
handleUpdateSend,
|
||||
handleDeleteSend,
|
||||
handleRemoveSendPassword,
|
||||
handleRemoveSendAuth,
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
handleAccessSendV2,
|
||||
handleAccessSendFileV2,
|
||||
handleDownloadSendFile,
|
||||
} from './handlers/sends';
|
||||
|
||||
// Sync handler
|
||||
import { handleSync } from './handlers/sync';
|
||||
|
||||
@@ -229,6 +248,38 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return handlePublicDownloadAttachment(request, env, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
// Public Send access endpoints
|
||||
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
||||
if (sendAccessMatch && method === 'POST') {
|
||||
const accessId = sendAccessMatch[1];
|
||||
return handleAccessSend(request, env, accessId);
|
||||
}
|
||||
|
||||
const sendAccessV2Match = path === '/api/sends/access';
|
||||
if (sendAccessV2Match && method === 'POST') {
|
||||
return handleAccessSendV2(request, env);
|
||||
}
|
||||
|
||||
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([a-f0-9-]+)$/i);
|
||||
if (sendAccessFileV2Match && method === 'POST') {
|
||||
const fileId = sendAccessFileV2Match[1];
|
||||
return handleAccessSendFileV2(request, env, fileId);
|
||||
}
|
||||
|
||||
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([a-f0-9-]+)$/i);
|
||||
if (sendAccessFileMatch && method === 'POST') {
|
||||
const idOrAccessId = sendAccessFileMatch[1];
|
||||
const fileId = sendAccessFileMatch[2];
|
||||
return handleAccessSendFile(request, env, idOrAccessId, fileId);
|
||||
}
|
||||
|
||||
const sendDownloadMatch = path.match(/^\/api\/sends\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||
if (sendDownloadMatch && method === 'GET') {
|
||||
const sendId = sendDownloadMatch[1];
|
||||
const fileId = sendDownloadMatch[2];
|
||||
return handleDownloadSendFile(request, env, sendId, fileId);
|
||||
}
|
||||
|
||||
// Notifications hub (stub - no auth required, return 200 for connection)
|
||||
if (path.startsWith('/notifications/')) {
|
||||
return new Response(null, { status: 200 });
|
||||
@@ -295,6 +346,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
featureStates: {
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'unauth-ui-refresh': true,
|
||||
},
|
||||
object: 'config',
|
||||
@@ -547,10 +599,40 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
}
|
||||
|
||||
// Sends endpoint (stub - not implemented)
|
||||
if (path === '/api/sends' || path.startsWith('/api/sends/')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
// Send endpoints
|
||||
if (path === '/api/sends') {
|
||||
if (method === 'GET') return handleGetSends(request, env, userId);
|
||||
if (method === 'POST') return handleCreateSend(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/sends/file/v2' && method === 'POST') {
|
||||
return handleCreateFileSendV2(request, env, userId);
|
||||
}
|
||||
|
||||
const sendMatch = path.match(/^\/api\/sends\/([a-f0-9-]+)(\/.*)?$/i);
|
||||
if (sendMatch) {
|
||||
const sendId = sendMatch[1];
|
||||
const subPath = sendMatch[2] || '';
|
||||
|
||||
if (subPath === '' || subPath === '/') {
|
||||
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
|
||||
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
|
||||
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
if (subPath === '/remove-password' && method === 'PUT') {
|
||||
return handleRemoveSendPassword(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
if (subPath === '/remove-auth' && method === 'PUT') {
|
||||
return handleRemoveSendAuth(request, env, userId, sendId);
|
||||
}
|
||||
|
||||
const sendFileUploadMatch = subPath.match(/^\/file\/([a-f0-9-]+)$/i);
|
||||
if (sendFileUploadMatch) {
|
||||
const fileId = sendFileUploadMatch[1];
|
||||
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
|
||||
if (method === 'POST') return handleUploadSendFile(request, env, userId, sendId, fileId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+110
-1
@@ -1,4 +1,4 @@
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog } from '../types';
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
@@ -39,6 +39,17 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS sends (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, data TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, password_hash TEXT, password_salt TEXT, password_iterations INTEGER, auth_type INTEGER NOT NULL DEFAULT 2, emails TEXT, ' +
|
||||
'max_access_count INTEGER, access_count INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, hide_email INTEGER, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expiration_date TEXT, deletion_date TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
@@ -768,6 +779,104 @@ export class StorageService {
|
||||
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
|
||||
}
|
||||
|
||||
// --- Sends ---
|
||||
|
||||
private mapSendRow(row: any): Send {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
notes: row.notes,
|
||||
data: row.data,
|
||||
key: row.key,
|
||||
passwordHash: row.password_hash,
|
||||
passwordSalt: row.password_salt,
|
||||
passwordIterations: row.password_iterations,
|
||||
authType: row.auth_type ?? SendAuthType.None,
|
||||
emails: row.emails ?? null,
|
||||
maxAccessCount: row.max_access_count,
|
||||
accessCount: row.access_count,
|
||||
disabled: !!row.disabled,
|
||||
hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
expirationDate: row.expiration_date,
|
||||
deletionDate: row.deletion_date,
|
||||
};
|
||||
}
|
||||
|
||||
async getSend(id: string): Promise<Send | null> {
|
||||
const row = await this.db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return this.mapSendRow(row);
|
||||
}
|
||||
|
||||
async saveSend(send: Send): Promise<void> {
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
|
||||
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
|
||||
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
|
||||
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date'
|
||||
);
|
||||
|
||||
await this.safeBind(
|
||||
stmt,
|
||||
send.id,
|
||||
send.userId,
|
||||
Number(send.type) || 0,
|
||||
send.name,
|
||||
send.notes,
|
||||
send.data,
|
||||
send.key,
|
||||
send.passwordHash,
|
||||
send.passwordSalt,
|
||||
send.passwordIterations,
|
||||
send.authType,
|
||||
send.emails,
|
||||
send.maxAccessCount,
|
||||
send.accessCount,
|
||||
send.disabled ? 1 : 0,
|
||||
send.hideEmail === null || send.hideEmail === undefined ? null : (send.hideEmail ? 1 : 0),
|
||||
send.createdAt,
|
||||
send.updatedAt,
|
||||
send.expirationDate,
|
||||
send.deletionDate
|
||||
).run();
|
||||
}
|
||||
|
||||
async deleteSend(id: string, userId: string): Promise<void> {
|
||||
await this.db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||
}
|
||||
|
||||
async getAllSends(userId: string): Promise<Send[]> {
|
||||
const res = await this.db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
return (res.results || []).map(row => this.mapSendRow(row));
|
||||
}
|
||||
|
||||
async getSendsPage(userId: string, limit: number, offset: number): Promise<Send[]> {
|
||||
const res = await this.db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
|
||||
)
|
||||
.bind(userId, limit, offset)
|
||||
.all<any>();
|
||||
return (res.results || []).map(row => this.mapSendRow(row));
|
||||
}
|
||||
|
||||
async deleteRefreshTokensByUserId(userId: string): Promise<void> {
|
||||
await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
|
||||
}
|
||||
|
||||
+57
-1
@@ -183,6 +183,62 @@ export interface Device {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export enum SendType {
|
||||
Text = 0,
|
||||
File = 1,
|
||||
}
|
||||
|
||||
export enum SendAuthType {
|
||||
Email = 0,
|
||||
Password = 1,
|
||||
None = 2,
|
||||
}
|
||||
|
||||
export interface Send {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: SendType;
|
||||
name: string;
|
||||
notes: string | null;
|
||||
data: string;
|
||||
key: string;
|
||||
passwordHash: string | null;
|
||||
passwordSalt: string | null;
|
||||
passwordIterations: number | null;
|
||||
authType: SendAuthType;
|
||||
emails: string | null;
|
||||
maxAccessCount: number | null;
|
||||
accessCount: number;
|
||||
disabled: boolean;
|
||||
hideEmail: boolean | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expirationDate: string | null;
|
||||
deletionDate: string;
|
||||
}
|
||||
|
||||
export interface SendResponse {
|
||||
id: string;
|
||||
accessId: string;
|
||||
type: number;
|
||||
name: string;
|
||||
notes: string | null;
|
||||
text: any | null;
|
||||
file: any | null;
|
||||
key: string;
|
||||
maxAccessCount: number | null;
|
||||
accessCount: number;
|
||||
password: string | null;
|
||||
emails: string | null;
|
||||
authType: SendAuthType;
|
||||
disabled: boolean;
|
||||
hideEmail: boolean | null;
|
||||
revisionDate: string;
|
||||
expirationDate: string | null;
|
||||
deletionDate: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
// JWT Payload
|
||||
export interface JWTPayload {
|
||||
sub: string; // user id
|
||||
@@ -318,7 +374,7 @@ export interface SyncResponse {
|
||||
ciphers: CipherResponse[];
|
||||
domains: any;
|
||||
policies: any[];
|
||||
sends: any[];
|
||||
sends: SendResponse[];
|
||||
// PascalCase for desktop/browser clients
|
||||
UserDecryptionOptions: UserDecryptionOptions | null;
|
||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||
|
||||
@@ -177,3 +177,140 @@ export async function verifyFileDownloadToken(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SendFileDownloadClaims {
|
||||
sendId: string;
|
||||
fileId: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export async function createSendFileDownloadToken(
|
||||
sendId: string,
|
||||
fileId: string,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: SendFileDownloadClaims = {
|
||||
sendId,
|
||||
fileId,
|
||||
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
return `${data}.${signatureB64}`;
|
||||
}
|
||||
|
||||
export async function verifySendFileDownloadToken(
|
||||
token: string,
|
||||
secret: string
|
||||
): Promise<SendFileDownloadClaims | null> {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
||||
if (!valid) return null;
|
||||
|
||||
const payload: SendFileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < now) return null;
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SendAccessTokenClaims {
|
||||
sub: string; // send id
|
||||
typ: 'send_access';
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export async function createSendAccessToken(sendId: string, secret: string): Promise<string> {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: SendAccessTokenClaims = {
|
||||
sub: sendId,
|
||||
typ: 'send_access',
|
||||
iat: now,
|
||||
exp: now + LIMITS.auth.sendAccessTokenTtlSeconds,
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
return `${data}.${signatureB64}`;
|
||||
}
|
||||
|
||||
export async function verifySendAccessToken(token: string, secret: string): Promise<SendAccessTokenClaims | null> {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
||||
if (!valid) return null;
|
||||
|
||||
const payload: SendAccessTokenClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < now) return null;
|
||||
if (payload.typ !== 'send_access') return null;
|
||||
if (!payload.sub) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user