Anyone have a solution for generating server-side tokens for the ESRI JSAPI SDK? - arcgis-js-api

There are a number of solutions to this:
use the build-in dialog provided by esri/IdentityManager (https://developers.arcgis.com/javascript/3/jsapi/identitymanagerbase-amd.html)
use a server-side proxy (https://github.com/Esri/resource-proxy)
use the identity manager initialize() method (https://developers.arcgis.com/javascript/3/jsapi/identitymanagerbase-amd.html#initialize)
But there what is missing is the ability to hook into the request for a token. I am working with ArcGISDynamicMapServiceLayer and there is no way to know if the server return a 498/499, and no way to update the url to update the token.
I started hacking around in the API to try to hook into various events with no real promise of success. What seems to be missing:
a way to detect when a token is needed
a way to update the token
Closes I came up with is listening for "dialog-create" but there is no way to disable the dialog apart from throwing an exception, which disables the layer.
I tried replacing the "_createLoginDialog" method and returning {open: true} as a trick to pause the layers until I had a token ready but since there is no way to update the layer endpoint I did not pursue this hack. It seems the only way this might work is to use the initialize() method on the identity manager.
Does anyone have knowledge of options beyond what I have outlined?
EDIT: The goal is to provide a single-sign-on experience to users of our product.
"User" is already signed in to our application
"User" wishes to access a secure ESRI ArcGIS Server MapServer or FeatureServer services from the ESRI JSAPI
"User" is prompted for user name and password
The desired flow is to acquire a token on the users behalf using a RESTful services in our product and return the appropriate token that will allow the "User" to access the secure services without being prompted.
I do not wish to use a proxy because I do not want all that traffic routed through the proxy.
I do not wish to use initialize() because it is complicated and not clear how that works apart for re-hydrating the credentials.
I do wish for an API that simply allows me to set the token on any layer services that report a 499 (missing token) or 498 (invalid token), but I cannot find any such API. The solution I am focusing on hinges on being able to update the url of an ArcGISImageServiceLayer instance with a new token.

This answer lacks in satisfaction but delivers on my requirements. I will start with the code (client-side typescript):
class TokenProxy {
private tokenAssuranceHash = {} as Dictionary<Promise<{ token: string, expiration: string }>>;
private service = new TokenService();
private timeoutHandle = 0;
watchLayer(esriLayer: ArcGISDynamicMapServiceLayer) {
setInterval(async () => {
const key = esriLayer._url.path;
const token = await this.tokenAssurance(key);
esriLayer._url.query.token = token;
}, 5000);
}
updateRefreshInterval(ticks: number) {
clearTimeout(this.timeoutHandle);
this.timeoutHandle = setTimeout(() => {
Object.keys(this.tokenAssuranceHash).forEach(url => {
this.tokenAssuranceHash[url] = this.service.getMapToken({serviceUrl: url});
});
this.updateRefreshInterval(ticks);
}, ticks);
}
async tokenAssurance(url: string) {
if (!this.tokenAssuranceHash[url]) {
this.tokenAssuranceHash[url] = this.service.getMapToken({serviceUrl: url});
}
try {
const response = await this.tokenAssuranceHash[url];
await this.recomputeRefreshInterval();
return response.token;
} catch (ex) {
console.error(ex, "could not acquire token");
return null;
}
}
async recomputeRefreshInterval() {
const keys = Object.keys(this.tokenAssuranceHash);
if (!keys.length) return;
const values = keys.map(k => this.tokenAssuranceHash[k]);
const tokens = await Promise.all(values);
const min = Math.min(...tokens.map(t => new Date(t.expiration).getTime()));
if (Number.isNaN(min)) return; // error occured, do not update the refresh interval
const nextRefreshInTicks = min - new Date().getTime();
this.updateRefreshInterval(0.90 * nextRefreshInTicks);
}
}
And highlight the hack that makes it work:
const key = esriLayer._url.path;
const token = await this.tokenAssurance(key);
esriLayer._url.query.token = token;
The "_url" is a hidden/private model that I should not be using to update the token but it works.

Related

NextJS/Next-Auth Backend Authentication with OAuth

I am currently building a web app based on a turborepo (monorepo) in which I want to use Discord OAuth login with next-auth. Therefore I have two modules web and api, where api is my express backend with discord.js. The web app is basically a dashboard for a Discord bot.
I figured that next-auth only provides client side authentication. So my question is how can I validate the OAuth session from the client side in the best manner?
My middleware for express currently looks like this:
function throwUnauthorized(res: Response) {
res.status(401).json({ code: 401, message: 'Unauthorized' });
}
export async function isAuthorized(req: Request, res: Response, next: NextFunction) {
try {
const authorization = req.headers.authorization;
if (!authorization) {
return throwUnauthorized(res);
}
// validate token with Discord API
const { data } = await axios.get('https://discord.com/api/oauth2/#me', {
headers: { Authorization: authorization },
});
// protect against token reuse
if (!data || data.application.id !== process.env.TC_DISCORD_CLIENT_ID) {
return throwUnauthorized(res);
}
// map to database user
let user = await User.findOne({ id: data.user.id });
user ??= await User.create({ id: data.user.id });
data.user.permissions = user.permissions;
req.user = data.user;
next();
} catch (error) {
return throwUnauthorized(res);
}
}
In this approach the Discord OAuth Token would be send via the Authorization header and checked before each request that requires Authorization. Which leads to my problem: The token needs to be validated again causing multiple request to Discord API.
Is there a better way to handle this? Because I need to map Discord user profiles to database profiles. I read that you could try decode the jwt session token from next-auth, but this did not work when I tested it.
Maybe there is a whole different project structure suggested for my project. But I thought I should separate the api and web-app since I would have needed a custom express server because it includes the Discord bot and Prometheus logging functions. I am open for suggestions and your thoughts!

API Authorization Tool

I'm working on an application with RESTful API endpoints that needs proper authorization security using an RBAC system. So far, I've looked into Keycloak. It looks promising at first but doesn't support granular authorization control of an endpoint, which is a hard requirement. For example, if I have the endpoint /object/<object:id>, a list of object IDs [1,2,3,4] and a test user, there's no way to restrict the test user to only have access to object IDs [1,2] but not [3,4] for the same endpoint. It seems the user will have access to all the IDs or none. Perhaps this can be accomplished by customizing or extending the base Keycloak server but there isn't enough documentation on the Keycloak website on how to do so.
I've done a search for other RBAC permissions systems but haven't been able to find much. Are there any authorization systems out there that can accomplish this?
but doesn't support granular authorization control of an endpoint
Check out Auth0's Fine Grained Authorization solution: https://docs.fga.dev/. (Disclaimer: I am employed by Auth0).
In your specific case you would need to create an authorization model like
type object
relations
define reader as self
And then add the following tuples in the FGA store using the Write API:
(user:test, relation:reader, object:1)
(user:test, relation:reader, object:2)
Then, in your API, you would do something like this:
const { Auth0FgaApi } = require('#auth0/fga')
const express = require('express')
const app = express()
const fgaClient = new Auth0FgaApi({
storeId: process.env.FGA_STORE_ID, // Fill this in!
clientId: process.env.FGA_CLIENT_ID, // Fill this in!
clientSecret: process.env.FGA_CLIENT_SECRET // Fill this in!
});
app.get('/objects/:id', async (req, res) => {
try {
const { allowed } = await fgaClient.check({
tuple_key: {
user: req.query.user,
relation: 'reader',
object: "object:" + req.params.id
}
});
if (!allowed) {
res.status(403).send("Unauthorized!")
} else {
res.status(200).send("Authorized!")
}
} catch (error) {
res.status(500).send(error)
}
});
const port = 3000
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})

google identity services equivalent for googleUser.getBasicProfile()

I have a react app where I'm trying to migrate from using gapi.auth2 module in the Google API Client Library for JavaScript to the Google Identity Services Library.
With gapi.auth2 module you could get the signed in users basic profile info with googleUser.getBasicProfile(). The following code is how you log a user in with the Google Identity Services Library.
Login.js
function Login(){
var tokenClient;
var access_token;
function getToken(){
tokenClient.requestAccessToken();
}
function initGis(){
tokenClient = window.google.accounts.oauth2.initTokenClient({
client_id: '********.apps.googleusercontent.com',
scope: 'https://www.googleapis.com/auth/books',
callback: (tokenResponse) => {
access_token = tokenResponse.access_token;
},//end of callback:
});
}
useEffect(()=>{
initGis();
getToken();
});
return (
<>
<p>Logging in...</p>
</>
)
}
export default Login;
How do you get the users basic profile info when using the Google Identity Services Library?
Let me keep this answer short.🙂 Once you get the access_token just invoke the following function:
const getUserProfileData = async (accessToken: string) => {
const headers = new Headers()
headers.append('Authorization', `Bearer ${accessToken}`)
const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers
})
const data = await response.json();
return data;
}
PS: [Unfortunately] I am also working on migrating to Google Identity Services Library. 😰
After a discussion on Discord where a very helpful user explained that it can only be done server side. So the simple answer is that it can't be done client side using the Google Identity Services Library
I was faced with the same issue migrating my web app to Google Identity Services. I resolved it by using the Google Drive API About:get method, and requested user fields. This returns the user's displayName and emailAddress (plus some other data that's really not very useful). I use the drive.readonly scope, but I believe a less sensitive scope like drive.appdata or drive.file would work.
You can try this:
function getTokenInfos(token) {
var splitResponse = token.split(".");
var infos = JSON.parse(atob(splitResponse[1]));
return infos;
}

Graphql #include with expression

I am implementing a query which should serve some fields in the response depending on user login status.
To be specific, I want to get "pointRate" field only if $authenticationToken is passed & would want to avoid passing $authenticated in below query. The reason I want to avoid sending $authenticated is client can do mistake by sending $authenticated = true but $authenticationToken = null.
query ToDoQuery($authenticationToken: String, $authenticated: Boolean!) {
pointRate(accessToken: $authenticationToken) #include(if: $authenticated) {
status
}
}
So, Actually you want to do that
i) if $authenticationToken is passed, you want to get "pointRate".
ii) and you also want to avoid passing $authenticated in subsequent
queries. Because you are concern about your clients who can make some
mistake like sending authenticated is true where authentication token
was null.
So in generally I want to answer that if you want to handle authentication by yourself using GraphQL, at first you have to create a token, then you have to pass the token in every request or with subsequent requests. Otherwise it is not possible. Because sensitive data's will not be provided without authentication.
On the other hand, you can use session auth. You can access every data until session is closed.
If it is not satisfactory, You can read the following brief description with a scenerio like yours. I also tried to accumulate some related sample solutions for better understanding, it may clarify you more.
As GraphQL API is completely public, you can make authentication by two ways.
Let the web server (e.g. express or nginx) take care of authentication.
Handle authentication in GraphQL itself.
If you do authentication in the web server, you can use a standard auth package (e.g. passport.js for express) and many existing authentication methods will work out of the box. You can also add and remove methods at your liking without modifying the GraphQL schema.
If you’re implementing authentication yourself, do the followings
Make sure to never store passwords in clear text or a MD5 or SHA-256
hash
Use something like bcrypt
Make sure to not store your session tokens as-is on the server, you
should hash them first
You can write a login method, which sets the context. Since mutations
are executed one after the other and not in parallel, you can be sure
the context is set after the login mutation:
mutation {
loginWithToken(token: "6e37a03e-9ee4-42fd-912d-3f67d2d0d852"),
do_stuff(greeting: "Hello", name: "Tom"),
do_more_stuff(submarine_color: "Yellow")
}
Instead of passing in the token via header or query parameter (like JWT, OAuth, etc), we make it part of the GraphQL query. Your schema code can parse the token directly using the JWT library itself or another tool.
Remember to always use HTTPS when passing sensitive information :)
As parallel execution is an important for performance. and mutation and queries are executed serially, in the order given.
So in most cases It is preferred to handle authentication in the web server. It’s not only more generic, but also more flexible.
Scenerio:
First go through the followings
import jwt from'express-jwt';
import graphqlHTTP from'express-graphql';
import express from'express';
import schema from'./mySchema';
const app = express();
app.use('/graphql', jwt({
secret: 'shhhhhhared-secret',
requestProperty: 'auth',
credentialsRequired: false,
}));
app.use('/graphql', function(req, res, done) {
const user = db.User.get(req.auth.sub);
req.context = {
user: user,
}
done();
});
app.use('/graphql', graphqlHTTP(req => ({
schema: schema,
context: req.context,
})
));
If you check in the above section, you will get that API is not secure at all. It might try to verify the JWT but if the JWT doesn’t exist or is invalid, the request will still pass through (see credentialsRequired: false). Why? We have to allow the request to pass through because if we blocked it we would block the entire API. That means, our users wouldn’t even be able to call a loginUser mutation to get a token to authenticate themselves.
Solution#1:
Barebone example using Authenticate resolvers, not endpoints.
import { GraphQLSchema } from 'graphql';
import { Registry } from 'graphql-helpers';
// The registry wraps graphql-js and is more concise
const registry = new Registry();
registry.createType(`
type User {
id: ID!
username: String!
}
`;
registry.createType(`
type Query {
me: User
}
`, {
me: (parent, args, context, info) => {
if (context.user) {
return context.user;
}
throw new Error('User is not logged in (or authenticated).');
},
};
const schema = new GraphQLSchema({
query: registry.getType('Query'),
});
By the time the request gets to our Query.me resolver, the server middleware has already tried to authenticate the user and fetch the user object from the database. In our resolver, we can then check the graphql context for the user (we set the context in our server.js file) and if one exists then return it else throw an error.
Note: you could just as easily return null instead of throwing an error and I would actually recommend it.
Solution#2:
Use functional Composition(middleware based) of express-graphql
import { GraphQLSchema } from 'graphql';
import { Registry } from 'graphql-helpers';
// See an implementation of compose https://gist.github.com/mlp5ab/f5cdee0fe7d5ed4e6a2be348b81eac12
import { compose } from './compose';
const registry = new Registry();
/**
* The authenticated function checks for a user and calls the next function in the composition if
* one exists. If no user exists in the context then an error is thrown.
*/
const authenticated =
(fn: GraphQLFieldResolver) =>
(parent, args, context, info) => {
if (context.user) {
return fn(parent, args, context, info);
}
throw new Error('User is not authenticated');
};
/*
* getLoggedInUser returns the logged in user from the context.
*/
const getLoggedInUser = (parent, args, context, info) => context.user;
registry.createType(`
type User {
id: ID!
username: String!
}
`;
registry.createType(`
type Query {
me: User
}
`, {
me: compose(authenticated)(getLoggedInUser)
};
const schema = new GraphQLSchema({
query: registry.getType('Query'),
});
The above code will work exactly the same as the first snippet. Instead of checking for the user in our main resolver function, we have created a highly reusable and testable middleware function that achieves the same thing. The immediate impact of this design may not be apparent yet but think about what would happen if we wanted to add another protected route as well as log our resolver running times. With our new design its as simple as:
const traceResolve =
(fn: GraphQLFieldResolver) =>
async (obj: any, args: any, context: any, info: any) => {
const start = new Date().getTime();
const result = await fn(obj, args, context, info);
const end = new Date().getTime();
console.log(`Resolver took ${end - start} ms`);
return result;
};
registry.createType(`
type Query {
me: User
otherSecretData: SecretData
}
`, {
me: compose(traceResolve, authenticated)(getLoggedInUser)
otherSecretData: compose(traceResolve, authenticated)(getSecretData)
};
Using this technique will help you build more robust GraphQL APIs. Function composition is a great solution for authentication tasks but you can also use it for logging resolvers, cleaning input, massaging output, and much more.
Solution#3:
A decent solution is to factor out data fetching into a separate layer and do the authorization check there.
Below is an example that follows the principles outlined above. It’s for a query that fetches all todo lists that a user can see.
For the following query,
{
allLists {
name
}
}
Don’t do this:
//in schema.js (just the essential bits)
allLists: {
resolve: (root, _, ctx) => {
return sql.raw("SELECT * FROM lists WHERE owner_id is NULL or owner_id = %s", ctx.user_id);
}
}
Instead, I suggest you do this:
// in schema.js (just the essential bits)
allLists: {
resolve: (root, _, ctx) => {
//factor out data fetching
return DB.Lists.all(ctx.user_id)
.then( lists => {
//enforce auth on each node
return lists.map(auth.List.enforce_read_perm(ctx.user_id) );
});
}
}
//in DB.js
export const DB = {
Lists: {
all: (user_id) => {
return sql.raw("SELECT id FROM lists WHERE owner_id is NULL or owner_id = %s, user_id);
}
}
}
//in auth.js
export const auth = {
List: {
enforce_read_perm: (user_id) => {
return (list) => {
if(list.owner_id !== null && list.owner_id !== user_id){
throw new Error("User not authorized to read list");
} else {
return list;
}
}
}
}
You may think that the DB.Lists.all function is already enforcing permissions, but the way I see it it’s just trying not to fetch too much data, the permissions themselves are enforced not on each node separately. That way you have the auth checks in one place and can be sure that they will be applied consistently, even if you fetch data in many different places.
Solution#4:
Auth flow can be done in many different ways.
i) basic auth,
ii) session auth, or
iii) token auth.
As your issue is according to token auth, I would like to meet you with Scaphold which one uses token authentication. Everything we do, whether it be logging a user into Scaphold or logging your user into your app, we use tokens to manage a user's auth status. The auth flow works like this:
a) User logs in with username and password.
b) The GraphQL server verifies the user in the database against his / her hashed password.
c) If successful, the server returns a JSON Web Token (JWT) that is a Base64 encoded token with an expiration date. This is the authentication token.
d) To use the authentication token, your future requests should include the authentication token in the header as
{ Authorization: 'Bearer' + [Auth_Token] }
Now, each time the server (perhaps Node Express) sees the token in the header, it will parse out the token, verify it, and in the GraphQL world, save the identified user in the context for use in the rest of the application. The user is now logged in.
For more, you can learn more about #include in this tutorial: https://github.com/mugli/learning-graphql/blob/master/4.%20Querying%20with%20Directives.md#include
For learning step by step graphql authentication, you can go through this tutorial: GraphQL Authentication
Resource Link:
Authentication with
GraphQL
A guide to authentication in
GraphQL
Best practices for GraphQL
security
I don't think this is possible since you cannot convert an (empty) String to a Boolean in GraphQL.
Also, some advice from the official GraphQL docs:
Delegate authorization logic to the business logic layer
#include
GraphQL queries are a powerful way to declare data in your application.
The include directive, allows us to include fields based on some condition.
query myAwesomeQuery($isAwesome: Boolean) {
awesomeField #include(if: $isAwesome)
}
Note. #skip always has higher precedence than #include.

asp.net core JWT in uri query parameter?

I have an api that is protected by JWT and Authorize attribute and at the client I use jquery ajax call to deal with it.
This works fine, however I now need to be able to secure downloading of files so I can't set a header Bearer value, can it be done in the URI as an url parameter?
=-=-=-=-
UPDATE: This is what I ended up doing for my scenario which is an in-house project and very low volume but security is important and it might need to scale in future:
When user logs in I generate a random download key and put it in their user record in the db along with the expiry date of their JWT and return the download key to the client. The download route is protected to only allow a download if there is a query parameter that has the download key and that key exists in the user records and that expiry date has not passed. This way the dl key is unique per user, valid as long as the user's auth session is valid and can be revoked easily.
This is a common problem.
Whenever you want to reference images or other files directly from an API in a single page application's HTML, there isn't a way to inject the Authorization request header between the <img> or <a> element and the request to the API. You can sidestep this by using some fairly new browser features as described here, but you may need to support browsers that lack this functionality.
Fortunately, RFC 6750 specifies a way to do exactly what you're asking via the "URI Query Parameter" authentication approach. If you follow its convention, you would accept JWTs using the following format:
https://server.example.com/resource?access_token=mF_9.B5f-4.1JqM&p=q
As stated in another answer and in RFC 6750 itself, you should be doing this only when necessary. From the RFC:
Because of the security weaknesses associated with the URI method (see Section 5), including the high likelihood that the URL containing the access token will be logged, it SHOULD NOT be used unless it is impossible to transport the access token in the "Authorization" request header field or the HTTP request entity-body.
If you still decide to implement "URI Query Parameter" authentication, you can use the Invio.Extensions.Authentication.JwtBearer library and call AddQueryStringAuthentication() extension method on JwtBearerOptions. Or, if you want to do it manually, you can certainly do that as well. Here's a code sample that shows both ways as extensions of the Microsoft.AspNetCore.Authentication.JwtBearer library.
public void ConfigureServices(IServiceCollection services) {
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(
options => {
var authentication = this.configuration.GetSection("Authentication");
options.TokenValidationParameters = new TokenValidationParameters {
ValidIssuers = authentication["Issuer"],
ValidAudience = authentication["ClientId"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(authentication["ClientSecret"])
)
};
// OPTION 1: use `Invio.Extensions.Authentication.JwtBearer`
options.AddQueryStringAuthentication();
// OPTION 2: do it manually
options.Events = new JwtBearerEvents {
OnMessageReceived = (context) => {
StringValues values;
if (!context.Request.Query.TryGetValue("access_token", out values)) {
return Task.CompletedTask;
}
if (values.Count > 1) {
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
context.Fail(
"Only one 'access_token' query string parameter can be defined. " +
$"However, {values.Count:N0} were included in the request."
);
return Task.CompletedTask;
}
var token = values.Single();
if (String.IsNullOrWhiteSpace(token)) {
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
context.Fail(
"The 'access_token' query string parameter was defined, " +
"but a value to represent the token was not included."
);
return Task.CompletedTask;
}
context.Token = token;
return Task.CompletedTask;
}
};
}
);
}
You can use a middleware to set the authorization header from the query param:
public class SecureDownloadUrlsMiddleware
{
private readonly RequestDelegate next;
public SecureDownloadUrlsMiddleware(RequestDelegate next)
{
this.next = next;
}
public async Task Invoke(HttpContext context /* other dependencies */)
{
// get the token from query param
var token = context.Request.Query["t"];
// set the authorization header only if it is empty
if (string.IsNullOrEmpty(context.Request.Headers["Authorization"]) &&
!string.IsNullOrEmpty(token))
{
context.Request.Headers["Authorization"] = $"Bearer {token}";
}
await next(context);
}
}
and then in Startup.cs use the middleware before the authentication middleware:
app.UseMiddleware(typeof(SecureDownloadUrlsMiddleware));
app.UseAuthentication();
Although it is technically possible to include a JWT in the URL, it is strongly discouraged. See the quote from here, which explains why it's a bad idea:
Don't pass bearer tokens in page URLs: Bearer tokens SHOULD NOT be
passed in page URLs (for example, as query string parameters).
Instead, bearer tokens SHOULD be passed in HTTP message headers or
message bodies for which confidentiality measures are taken. Browsers,
web servers, and other software may not adequately secure URLs in the
browser history, web server logs, and other data structures. If bearer
tokens are passed in page URLs, attackers might be able to steal them
from the history data, logs, or other unsecured locations.
However, if you have no choice or just don't care about security practices, see Technetium's answer.
If you still need it,you have to set jwt token on localStorage.After,you have to create a new header with the following code:
'functionName'():Headers{
let header =new Headers();
let token = localStorage.getItem('token')
header.append('Authorization',`Bearer ${token}`);
return header;
}
Add Hader to http requests.
return this.http.get('url',new RequestOptions({headers:this.'serviceName'.'functionName'()}))
Although this is a bit outside of the box, I would advice you to do the same as this is the best scalable solution when developing in the .NET environment.
Use Azure Storage! Or any other similar online cloud storage solution.
It makes sure your web app is separate from your files, so you don't have to worry about moving an application to a different web environment.
Web storage is mostly more expensive then azure storage (1GB with about 3000 operations (read/write/list) costs in total about $0.03.
When you scale your application where downtime is more critical, point 1 also applies when you use a swapping/staging technique.
Azure storage takes care of the expiry of so called Shared Access Tokens (SAS)
For the sake of simplicity for you, I will just include my code here so you don't have to google the rest
So what I do in my case, all my files are saved as Attachments within the database (not the actual file of course).
When someone requests an attachment, I do a quick check to see if the expire date has passed and if so we should generate a new url.
//where ever you want this to happen, in the controller before going to the client for example
private async Task CheckSasExpire(IEnumerable<AttachmentModel> attachments)
{
foreach (AttachmentModel attachment in attachments)
{
await CheckSasExpire(attachment);
}
}
private async Task CheckSasExpire(AttachmentModel attachment)
{
if (attachment != null && attachment.LinkExpireDate < DateTimeOffset.UtcNow && !string.IsNullOrWhiteSpace(attachment.AzureContainer))
{
Enum.TryParse(attachment.AzureContainer, out AzureStorage.ContainerEnum container);
string url = await _azureStorage.GetFileSasLocator(attachment.Filename, container);
attachment.FileUrl = url;
attachment.LinkExpireDate = DateTimeOffset.UtcNow.AddHours(1);
await _attachmentRepository.UpdateAsync(attachment.AttachmentId, attachment);
}
}
AzureStorage.ContainerEnum is just an internal enum to easily track the container certain files are stored in, but these can be strings of course
And my AzureStorage class:
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
public async Task<string> GetFileSasLocator(string filename, ContainerEnum container, DateTimeOffset expire = default(DateTimeOffset))
{
var cont = await GetContainer(container);
CloudBlockBlob blockBlob = cont.GetBlockBlobReference(filename);
DateTimeOffset expireDate = DateTimeOffset.UtcNow.AddHours(1);//default
if (expire != default(DateTimeOffset) && expire > expireDate)
{
expireDate = expire.ToUniversalTime();
}
SharedAccessBlobPermissions permission = SharedAccessBlobPermissions.Read;
var sasConstraints = new SharedAccessBlobPolicy
{
SharedAccessStartTime = DateTime.UtcNow.AddMinutes(-30),
SharedAccessExpiryTime = expireDate,
Permissions = permission
};
var sasToken = blockBlob.GetSharedAccessSignature(sasConstraints);
return blockBlob.Uri + sasToken;
}
private async Task<CloudBlobContainer> GetContainer(ContainerEnum container)
{
//CloudConfigurationManager.GetSetting("StorageConnectionString")
CloudStorageAccount storageAccount = CloudStorageAccount.Parse(_config["StorageConnectionString"]);
CloudBlobClient blobClient = storageAccount.CreateCloudBlobClient();
string containerName = container.ToString().ToLower();
CloudBlobContainer cloudContainer = blobClient.GetContainerReference(containerName);
await cloudContainer.CreateIfNotExistsAsync();
return cloudContainer;
}
So this will produce url's like so: http://127.0.0.1:10000/devstoreaccount1/invoices/NL3_2002%20-%202019-04-12.pdf?sv=2018-03-28&sr=b&sig=gSiohA%2BGwHj09S45j2Deh%2B1UYP1RW1Fx5VGeseNZmek%3D&st=2019-04-18T14%3A16%3A55Z&se=2019-04-18T15%3A46%3A55Z&sp=r
Of course you have to apply your own authentication logic when retrieving the attachments, if the user is allowed to view the file or not. But that can all be done with the JWT token and in the controller or the repository. I wouldn't worry about the URL being a public url, if one is so mighty to get that URL... within one hour... well then reduce the expire date :D