src/blob/authentication/BlobSASAuthenticator.ts (549 lines of code) (raw):
import IAccountDataStore from "../../common/IAccountDataStore";
import ILogger from "../../common/ILogger";
import BlobStorageContext from "../context/BlobStorageContext";
import StorageErrorFactory from "../errors/StorageErrorFactory";
import StrictModelNotSupportedError from "../errors/StrictModelNotSupportedError";
import { AccessPolicy, BlobType } from "../generated/artifacts/models";
import Operation from "../generated/artifacts/operation";
import Context from "../generated/Context";
import IRequest from "../generated/IRequest";
import IBlobMetadataStore from "../persistence/IBlobMetadataStore";
import { AUTHENTICATION_BEARERTOKEN_REQUIRED } from "../utils/constants";
import { getUserDelegationKeyValue } from "../utils/utils";
import { BlobSASPermission } from "./BlobSASPermissions";
import { BlobSASResourceType } from "./BlobSASResourceType";
import IAuthenticator from "./IAuthenticator";
import {
generateBlobSASSignature,
generateBlobSASSignatureWithUDK,
IBlobSASSignatureValues
} from "./IBlobSASSignatureValues";
import {
OPERATION_BLOB_SAS_BLOB_PERMISSIONS,
OPERATION_BLOB_SAS_CONTAINER_PERMISSIONS
} from "./OperationBlobSASPermission";
export default class BlobSASAuthenticator implements IAuthenticator {
public constructor(
private readonly accountDataStore: IAccountDataStore,
private readonly blobMetadataStore: IBlobMetadataStore,
private readonly logger: ILogger
) {}
public async validate(
req: IRequest,
context: Context
): Promise<boolean | undefined> {
this.logger.info(
`BlobSASAuthenticator:validate() Start validation against blob service Shared Access Signature pattern.`,
context.contextId
);
this.logger.debug(
"BlobSASAuthenticator:validate() Getting account properties...",
context.contextId
);
const blobContext = new BlobStorageContext(context);
const account = blobContext.account;
if (account === undefined) {
throw RangeError(
`BlobSASAuthenticator:validate() account is undefined in context.`
);
}
const containerName = blobContext.container;
if (containerName === undefined) {
this.logger.error(
`BlobSASAuthenticator:validate() container name is undefined in context.`,
context.contextId
);
return undefined;
}
const blobName = blobContext.blob;
this.logger.debug(
// tslint:disable-next-line:max-line-length
`BlobSASAuthenticator:validate() Retrieved account name from context: ${account}, container: ${containerName}, blob: ${blobName}`,
context.contextId
);
// TODO: Make following async
const accountProperties = this.accountDataStore.getAccount(account);
if (accountProperties === undefined) {
throw StorageErrorFactory.ResourceNotFound(
blobContext.contextId!
);
}
this.logger.debug(
"BlobSASAuthenticator:validate() Got account properties successfully.",
context.contextId
);
// Extract blob service SAS authentication required parameters
const signature = this.decodeIfExist(req.getQuery("sig"));
this.logger.debug(
`BlobSASAuthenticator:validate() Retrieved signature from URL parameter sig: ${signature}`,
context.contextId
);
if (signature === undefined) {
this.logger.debug(
`BlobSASAuthenticator:validate() No signature found in request. Skip blob service SAS validation.`,
context.contextId
);
return undefined;
}
const resource = this.decodeIfExist(req.getQuery("sr"));
if (
resource !== BlobSASResourceType.Container &&
resource !== BlobSASResourceType.Blob &&
resource !== BlobSASResourceType.BlobSnapshot
) {
this.logger.debug(
// tslint:disable-next-line:max-line-length
`BlobSASAuthenticator:validate() Signed resource type ${resource} is invalid. Skip blob service SAS validation.`,
context.contextId
);
return undefined;
}
this.logger.debug(
`BlobSASAuthenticator:validate() Signed resource type is ${resource}.`,
context.contextId
);
const values = this.getBlobSASSignatureValuesFromRequest(
req,
containerName,
blobName,
context
);
if (values === undefined) {
this.logger.info(
// tslint:disable-next-line:max-line-length
`BlobSASAuthenticator:validate() Failed to get valid blob service SAS values from request. Skip blob service SAS validation.`,
context.contextId
);
return undefined;
}
this.logger.debug(
`BlobSASAuthenticator:validate() Successfully got valid blob service SAS values from request. ${JSON.stringify(
values
)}`,
context.contextId
);
if (!context.context.loose && values.encryptionScope !== undefined)
{
throw new StrictModelNotSupportedError("SAS Encryption Scope 'ses'", context.contextId);
}
if (values.signedObjectId
|| values.signedTenantId
|| values.signedService
|| values.signedVersion
|| values.signedStartsOn
|| values.signedExpiresOn) {
this.logger.info(
`BlobSASAuthenticator:validate() Validate signature based on user delegation key.`,
context.contextId
);
if (!values.signedObjectId
|| !values.signedTenantId
|| !values.signedStartsOn
|| !values.signedExpiresOn
|| !values.signedService
|| !values.signedVersion
|| values.signedService !== "b") {
this.logger.info(
`BlobSASAuthenticator:validate() Signature based on user delegation key validation failed"
}.`,
context.contextId
);
throw StorageErrorFactory.getAuthorizationFailure(context.contextId!);
}
const savedPolicy = this.decodeIfExist(req.getQuery("si"));
if (savedPolicy) {
this.logger.info(
`BlobSASAuthenticator:validate() Access policy used in UDK SAS.`,
context.contextId
);
throw StorageErrorFactory.getAuthorizationFailure(context.contextId!);
}
this.logger.info(
`BlobSASAuthenticator:validate() Validate UDK start and expiry time.`,
context.contextId
);
if (!this.validateTime(values.signedExpiresOn!, values.signedStartsOn!)) {
this.logger.info(
`BlobSASAuthenticator:validate() Validate UDK start and expiry failed.`,
context.contextId
);
throw StorageErrorFactory.getAuthorizationFailure(context.contextId!);
}
const keyValue = getUserDelegationKeyValue(
values.signedObjectId!,
values.signedTenantId!,
values.signedStartsOn!,
values.signedExpiresOn!,
values.signedVersion!
);
const [sig, stringToSign] = generateBlobSASSignatureWithUDK(
values,
resource,
account,
Buffer.from(keyValue, "base64")
);
this.logger.debug(
`BlobSASAuthenticator:validate() String to sign is: ${JSON.stringify(
stringToSign
)}`,
context.contextId!
);
this.logger.debug(
`BlobSASAuthenticator:validate() Calculated signature is: ${sig}`,
context.contextId!
);
const sigPass = sig === signature;
this.logger.info(
`BlobSASAuthenticator:validate() Signature based on UDK ${
sigPass ? "passed" : "failed"
}.`,
context.contextId
);
if (!sigPass) {
return sigPass;
}
}
else {
this.logger.info(
`BlobSASAuthenticator:validate() Validate signature based account key1.`,
context.contextId
);
const [sig1, stringToSign1] = generateBlobSASSignature(
values,
resource,
account,
accountProperties.key1
);
this.logger.debug(
`BlobSASAuthenticator:validate() String to sign is: ${JSON.stringify(
stringToSign1
)}`,
context.contextId!
);
this.logger.debug(
`BlobSASAuthenticator:validate() Calculated signature is: ${sig1}`,
context.contextId!
);
const sig1Pass = sig1 === signature;
this.logger.info(
`BlobSASAuthenticator:validate() Signature based on key1 validation ${
sig1Pass ? "passed" : "failed"
}.`,
context.contextId
);
if (accountProperties.key2 !== undefined) {
this.logger.info(
`BlobSASAuthenticator:validate() Account key2 is not empty, validate signature based account key2.`,
context.contextId
);
const [sig2, stringToSign2] = generateBlobSASSignature(
values,
resource,
account,
accountProperties.key2
);
this.logger.debug(
`BlobSASAuthenticator:validate() String to sign is: ${JSON.stringify(
stringToSign2
)}`,
context.contextId!
);
this.logger.debug(
`BlobSASAuthenticator:validate() Calculated signature is: ${sig2}`,
context.contextId!
);
const sig2Pass = sig2 === signature;
this.logger.info(
`BlobSASAuthenticator:validate() Signature based on key2 validation ${
sig2Pass ? "passed" : "failed"
}.`,
context.contextId
);
if (!sig2Pass && !sig1Pass) {
this.logger.info(
`BlobSASAuthenticator:validate() Validate signature based account key1 and key2 failed.`,
context.contextId
);
return false;
}
} else {
if (!sig1Pass) {
return false;
}
}
}
// When signature validation passes, we enforce blob service SAS validation
// Any validation errors will stop this request immediately
// TODO: Validate permissions from ACL identifier by extract permissions, start time and expiry time from ACL
if (values.identifier !== undefined) {
const accessPolicy:
| AccessPolicy
| undefined = await this.getContainerAccessPolicyByIdentifier(
account,
containerName,
values.identifier,
context
);
if (accessPolicy === undefined) {
this.logger.warn(
`BlobSASAuthenticator:validate() Cannot get access policy defined for container ${containerName} with id ${values.identifier}.`,
context.contextId
);
throw StorageErrorFactory.getAuthorizationFailure(context.contextId!);
}
values.startTime = accessPolicy.start;
values.expiryTime = accessPolicy.expiry;
values.permissions = accessPolicy.permission;
}
this.logger.info(
`BlobSASAuthenticator:validate() Validate start and expiry time.`,
context.contextId
);
if (!this.validateTime(values.expiryTime, values.startTime)) {
this.logger.info(
`BlobSASAuthenticator:validate() Validate start and expiry failed.`,
context.contextId
);
throw StorageErrorFactory.getAuthorizationFailure(context.contextId!);
}
this.logger.info(
`BlobSASAuthenticator:validate() Validate IP range.`,
context.contextId
);
if (!this.validateIPRange()) {
this.logger.info(
`BlobSASAuthenticator:validate() Validate IP range failed.`,
context.contextId
);
throw StorageErrorFactory.getAuthorizationSourceIPMismatch(
context.contextId!
);
}
this.logger.info(
`BlobSASAuthenticator:validate() Validate request protocol.`,
context.contextId
);
if (!this.validateProtocol(values.protocol, req.getProtocol())) {
this.logger.info(
`BlobSASAuthenticator:validate() Validate protocol failed.`,
context.contextId
);
throw StorageErrorFactory.getAuthorizationProtocolMismatch(
context.contextId!
);
}
const operation = context.operation;
if (operation === undefined) {
throw new Error(
// tslint:disable-next-line:max-line-length
`BlobSASAuthenticator:validate() Operation shouldn't be undefined. Please make sure DispatchMiddleware is hooked before authentication related middleware.`
);
}
else if (operation === Operation.Service_GetUserDelegationKey) {
this.logger.info(
`BlobSASAuthenticator:validate() Service_GetUserDelegationKey requires OAuth credentials"
}.`,
context.contextId
);
throw StorageErrorFactory.getAuthenticationFailed(context.contextId!,
AUTHENTICATION_BEARERTOKEN_REQUIRED);
}
const blobSASPermission =
resource === BlobSASResourceType.Blob
? OPERATION_BLOB_SAS_BLOB_PERMISSIONS.get(operation)
: OPERATION_BLOB_SAS_CONTAINER_PERMISSIONS.get(operation);
this.logger.debug(
`BlobSASAuthenticator:validate() Got permission requirements for operation ${
Operation[operation]
} - ${JSON.stringify(blobSASPermission)}`,
context.contextId
);
if (blobSASPermission === undefined) {
throw new Error(
// tslint:disable-next-line:max-line-length
`BlobSASAuthenticator:validate() ${
resource === BlobSASResourceType.Blob
? "OPERATION_BLOB_SAS_BLOB_PERMISSIONS"
: "OPERATION_BLOB_SAS_CONTAINER_PERMISSIONS"
} doesn't have configuration for operation ${
Operation[operation]
}'s blob service SAS permission.`
);
}
if (!blobSASPermission.validatePermissions(values.permissions!)) {
throw StorageErrorFactory.getAuthorizationPermissionMismatch(
context.contextId!
);
}
// Check 3 special permission requirements
// If block blob exists, then permission must be Write only
// If page blob exists, then permission must be Write only
// If append blob exists, then permission must be Write only
// If copy destination blob exists, then permission must be Write only
if (
operation === Operation.BlockBlob_Upload ||
operation === Operation.PageBlob_Create ||
operation === Operation.AppendBlob_Create ||
operation === Operation.Blob_StartCopyFromURL ||
operation === Operation.Blob_CopyFromURL
) {
this.logger.info(
`BlobSASAuthenticator:validate() For ${Operation[operation]}, if blob exists, the permission must be Write.`,
context.contextId
);
if (
(await this.blobExist(account, containerName!, blobName!)) &&
!values.permissions!.toString().includes(BlobSASPermission.Write)
) {
this.logger.info(
`BlobSASAuthenticator:validate() Account SAS validation failed for special requirement.`,
context.contextId
);
throw StorageErrorFactory.getAuthorizationPermissionMismatch(
context.contextId!
);
}
}
this.logger.info(
`BlobSASAuthenticator:validate() Blob service SAS validation successfully.`,
context.contextId
);
// TODO: Handle enforced response headers defined in blob service SAS
return true;
}
private getBlobSASSignatureValuesFromRequest(
req: IRequest,
containerName: string,
blobName?: string,
context?: Context
): IBlobSASSignatureValues | undefined {
const version = this.decodeIfExist(req.getQuery("sv"));
const protocol = this.decodeIfExist(req.getQuery("spr"));
const startTime = this.decodeIfExist(req.getQuery("st"));
const expiryTime = this.decodeIfExist(req.getQuery("se"));
const permissions = this.decodeIfExist(req.getQuery("sp"));
const ipRange = this.decodeIfExist(req.getQuery("sip"));
const identifier = this.decodeIfExist(req.getQuery("si"));
const cacheControl = req.getQuery("rscc");
const contentDisposition = req.getQuery("rscd");
const contentEncoding = req.getQuery("rsce");
const contentLanguage = req.getQuery("rscl");
const contentType = req.getQuery("rsct");
const signedResource = this.decodeIfExist(req.getQuery("sr"));
const snapshot = this.decodeIfExist(req.getQuery("snapshot"));
const encryptionScope = this.decodeIfExist(req.getQuery("ses"));
const signedObjectId = this.decodeIfExist(req.getQuery("skoid"));
const signedTenantId = this.decodeIfExist(req.getQuery("sktid"));
const signedStartsOn = this.decodeIfExist(req.getQuery("skt"));
const signedExpiresOn = this.decodeIfExist(req.getQuery("ske"));
const signedVersion = this.decodeIfExist(req.getQuery("skv"));
const signedService = this.decodeIfExist(req.getQuery("sks"));
if (!identifier && (!permissions || !expiryTime)) {
this.logger.warn(
// tslint:disable-next-line:max-line-length
`BlobSASAuthenticator:generateBlobSASSignature(): Must provide 'permissions' and 'expiryTime' for Blob SAS generation when 'identifier' is not provided.`,
context ? context.contextId : undefined
);
return undefined;
}
if (version === undefined) {
this.logger.warn(
// tslint:disable-next-line:max-line-length
`BlobSASAuthenticator:generateBlobSASSignature(): Must provide 'version'.`,
context ? context.contextId : undefined
);
return undefined;
}
const blobSASValues: IBlobSASSignatureValues = {
version,
protocol,
startTime,
expiryTime,
permissions,
ipRange,
containerName,
blobName,
identifier,
encryptionScope,
cacheControl,
contentDisposition,
contentEncoding,
contentLanguage,
contentType,
signedResource,
snapshot,
signedObjectId,
signedTenantId,
signedService,
signedVersion,
signedStartsOn,
signedExpiresOn
};
return blobSASValues;
}
private validateTime(expiry?: Date | string, start?: Date | string): boolean {
if (expiry === undefined && start === undefined) {
return true;
}
const now = new Date();
if (expiry !== undefined) {
const expiryTime = new Date(expiry);
if (now > expiryTime) {
return false;
}
}
if (start !== undefined) {
const startTime = new Date(start);
if (now < startTime) {
return false;
}
}
return true;
}
private validateIPRange(): boolean {
// TODO: Emulator doesn't validate IP Address
return true;
}
private validateProtocol(
sasProtocol: string = "https,http",
requestProtocol: string
): boolean {
if (sasProtocol.includes(",")) {
return true;
} else {
return sasProtocol.toLowerCase() === requestProtocol;
}
}
private decodeIfExist(value?: string): string | undefined {
return value === undefined ? value : decodeURIComponent(value);
}
private async getContainerAccessPolicyByIdentifier(
account: string,
container: string,
id: string,
context: Context
): Promise<AccessPolicy | undefined> {
try {
const containerModel = await this.blobMetadataStore.getContainerACL(
context,
account,
container
);
if (containerModel === undefined) {
return undefined;
}
if (containerModel.containerAcl === undefined) {
return undefined;
}
for (const acl of containerModel.containerAcl) {
if (acl.id === id) {
return acl.accessPolicy;
}
}
} catch (err) {
return undefined;
}
}
private async blobExist(
account: string,
container: string,
blob: string
): Promise<boolean> {
const blobModel = await this.blobMetadataStore.getBlobType(
account,
container,
blob
);
if (blobModel === undefined) {
return false;
}
if (
blobModel.blobType === BlobType.BlockBlob &&
blobModel.isCommitted === false
) {
return false;
}
return true;
}
}