serilog-ui
serilog-ui copied to clipboard
Feature: Allow custom authorization
Hiya,
Right now i keep having to add my token into the log view - this can be a bit tedious. Would it be possible to use authorization the same way hangfire does, by allowing custom Authorization filters?
See: https://docs.hangfire.io/en/latest/configuration/using-dashboard.html
The code looks simple enough from a first glance:
public interface IDashboardAsyncAuthorizationFilter
{
Task<bool> AuthorizeAsync([NotNull] DashboardContext context);
}
Hangfire.Dashboard.AspNetCoreDashboardMiddleware
foreach (IDashboardAuthorizationFilter authorizationFilter in this._options.Authorization)
{
if (!authorizationFilter.Authorize((DashboardContext) context))
{
httpContext.Response.StatusCode = AspNetCoreDashboardMiddleware.GetUnauthorizedStatusCode(httpContext);
context = (AspNetCoreDashboardContext) null;
findResult = (Tuple<IDashboardDispatcher, Match>) null;
return;
}
}
foreach (IDashboardAsyncAuthorizationFilter authorizationFilter in this._options.AsyncAuthorization)
{
if (!await authorizationFilter.AuthorizeAsync((DashboardContext) context))
{
httpContext.Response.StatusCode = AspNetCoreDashboardMiddleware.GetUnauthorizedStatusCode(httpContext);
context = (AspNetCoreDashboardContext) null;
findResult = (Tuple<IDashboardDispatcher, Match>) null;
return;
}
}
Startup:
endpoints.MapHangfireDashboard(new DashboardOptions
{
AsyncAuthorization = new[] {
new MyHangfireAuthenticationFilter(
new(
authApiUrl,
publicKeyUrl
),
services.GetRequiredService<ILogger<MYHangfireAuthenticationFilter>>()
)
}
});
Custom filter:
public class MyHangfireAuthenticationFilter : IDashboardAsyncAuthorizationFilter
{
....
public async Task<bool> AuthorizeAsync(DashboardContext context)
{
var httpContext = context.GetHttpContext();
if (_options.ForceSsl && httpContext.Request.Scheme != "https")
{
var redirectUri = new UriBuilder("https", httpContext.Request.Host.ToString(), 443, httpContext.Request.Path).ToString();
httpContext.Response.Redirect(redirectUri, true);
return false;
}
// If cookie has a JWT token we don't have to send a request to the auth api, we can just validate the token
if (httpContext.Request.Cookies.ContainsKey("token"))
{
try
{
_ = new JwtSecurityTokenHandler()
.ValidateToken(httpContext.Request.Cookies["token"],
new()
{
ClockSkew = TimeSpan.Zero,
ValidateAudience = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
IssuerSigningKey = await GetSecurityKey()
}, out _);
return true;
}
catch(Exception ex)
{
// Validation failed
_logger.LogError(ex, ex.Message);
}
}
// No token or invalid token, check for basic auth headers to validate
var authHeader = httpContext.Request.Headers["Authorization"];
if (!string.IsNullOrWhiteSpace(authHeader))
{
var authValues = AuthenticationHeaderValue.Parse(authHeader);
if (authValues.Scheme.Equals("Basic", StringComparison.InvariantCultureIgnoreCase))
{
if (string.IsNullOrWhiteSpace(authValues.Parameter))
throw new("string.IsNullOrWhiteSpace(authValues.Parameter)");
var authString = Encoding.UTF8.GetString(Convert.FromBase64String(authValues.Parameter));
var parts = authString.Split(':', 2);
if (parts.Length == 2)
{
var body = new
{
Email = parts[0],
Password = parts[1]
};
try
{
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8,
"application/json");
// Some custom auth logic with an external server
var result = await HttpClient.PostAsync($"{_options.AuthUrl}/api/authorize/login", content);
if (result.IsSuccessStatusCode)
{
var response = await result.Content.ReadAsStringAsync();
var jsonResponse = JsonSerializer.Deserialize<JsonDocument>(response) ?? throw new NullReferenceException("JsonSerializer.Deserialize<JsonDocument>(response)");
var token = jsonResponse.RootElement.GetProperty("token").GetString() ?? throw new NullReferenceException("jsonResponse.RootElement.GetProperty(\"token\").GetString()");
httpContext.Response.Cookies.Append("token", token);
return true;
}
}
catch (Exception ex)
{
_logger.LogError(ex, ex.Message);
}
}
}
}
await ShowChallenge(httpContext);
return false;
}
private async Task ShowChallenge(HttpContext context)
{
context.Response.StatusCode = 401;
context.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"Hangfire Dashboard\"");
await context.Response.Body.WriteAsync(Encoding.UTF8.GetBytes("Authentication is required."));
}
private async ValueTask<SecurityKey> GetSecurityKey()
{
if (_key == null)
{
_key = ParsePublicKey(await HttpClient.GetStringAsync(_options.CertificateUrl));
}
return _key;
}
private RsaSecurityKey ParsePublicKey(string key)
{
var rsa = RSA.Create();
var cert = new X509Certificate(Encoding.UTF8.GetBytes(key));
rsa.ImportRSAPublicKey(cert.GetPublicKey(), out _);
return new(rsa);
}
}
public readonly struct HangfireAuthenticationOptions
{
public string AuthUrl { get; }
public string CertificateUrl { get; }
public bool ForceSsl { get; }
public HangfireAuthenticationOptions(string url, string certificateUrl, bool forceSsl = true)
{
AuthUrl = url;
CertificateUrl = certificateUrl;
ForceSsl = forceSsl;
}
}
This way we'll get the native browser login modal, which makes it easy to log in (especially since the browser can remember my account details)

I'd prefer:
- An Async interface
- DI so that i can inject an ILogger
I followed the Swashbuckle Swagger approach for authentication, simple and easy to implement. I don't think there is a problem with storing token in session storage, however, there is a PR for UI and not merged yet and after merging that, it can be implemented and you are very welcome to help.
@followynne the token in the frontend is currently saved in localstorage, would it be okay for you to change this to a cookie? That way i can set the cookie from my own system and that way auto-login to serilog-ui
This library supports cookie. If your authentication mechanism is based on the cookie and as long as you are logged in, for each request to get logs, the authentication cookie will be sent to the server.
This library supports cookie. If your authentication mechanism is based on the cookie and as long as you are logged in, for each request to get logs, the authentication cookie will be sent to the server.
Yeah I understand what your're saying but I thought the UI saves the auth cookie in local storage and im asking if it could be moved to cookie storage instead.
Il share a snippet tomorrow
This library supports cookie. If your authentication mechanism is based on the cookie and as long as you are logged in, for each request to get logs, the authentication cookie will be sent to the server.
I meant this code: https://github.com/mo-esmp/serilog-ui/blob/f2936c8d4912b6b115c124668be95c976bbe1ccb/src/Serilog.Ui.Web/assets/script/authentication.ts#L27-L37
It uses sessionStorage, i'd like for it to be moved to a cookie if possible. That way i can also set that cookie elsewhere and i'll be automatically logged in.
And then from my front-end i can do this:
// Is called after user logs in
public setAuthorization(auth: IAuthorization): void {
localStorage.setItem('at', btoa(JSON.stringify(auth)));
// serilog-ui.
document.cookie = 'serilogui_token=Bearer ${auth.token}';
// Hangfire cookie See: https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie
// Is set with lifetime as session and should work for subdomains, so hangfire should be opened in the same window.
// This works in chrome by using a '.' (dot) to set the cookie across subdomains see: https://stackoverflow.com/questions/18492576/share-cookie-between-subdomain-and-domain
// opg.systems or localhost (w/o scheme or port)
const url = new URL(document.location.toString());
const hasExtension = url.hostname.split('.').length > 1
const hostname = ( hasExtension ? '.' : '' ) + url.hostname;
document.cookie = `token=${auth.token};domain=${hostname};`;
// TODO add swagger auth (need to configure oath flow in backend.)
}
I don't think the change from sessionStorage - cookie has any impact, but that would help me a lot.
Mitigated by #40,
Can now use Front end JS to fetch and set token in some manor.
Ex, from (cross-domain) cookie:
/* https://stackoverflow.com/a/25346429/4122889 */
function getCookie(name) {
function escape(s) { return s.replace(/([.*+?\^$(){}|\[\]\/\\])/g, "\\$1"); }
var match = document.cookie.match(RegExp("(?:^|;\\s*)" + escape(name) + "=([^;]*)"));
return match ? match[1] : null;
}
(function () {
console.log("custom.js is loaded.");
var token = getCookie("token");
if (token) {
sessionStorage.setItem("serilogui_token", 'Bearer ' + token);
}
})();
@sommmen, you're welcome to add a description for this use case to the Readme file.