duplicati/Duplicati/WebserverCore/Endpoints/V1/Auth.cs
Kenneth Skovhede e3f1aefec2 Implemented a nonce for refresh tokens
This adds a nonce to the refresh token such that each request to obtain a refresh token must now also provide a matching nonce.

When using non-persisted logins, the request to the server is the same, but the "remember me" flag toggles a shorter duration for the refresh token.

The FE can then store the nonce in either local storage for persisted logins or in session storage for non-persisted logins.

The default is currently to always issue refresh tokens with a nonce, but this can be toggled with the JWT configuration.

The ngax client does not have the non-persisted login so it stores the nonce in local storage, using a name that is compatible with ngclient so the user can swap between them without needing to re-login.

The server util was updated to also store the nonce.

This fixes #6451
2025-08-07 23:10:54 +02:00

182 lines
9.1 KiB
C#

// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Duplicati.Library.Logging;
using Duplicati.Server.Database;
using Duplicati.WebserverCore.Abstractions;
using Duplicati.WebserverCore.Exceptions;
using Duplicati.WebserverCore.Middlewares;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace Duplicati.WebserverCore.Endpoints.V1;
public partial class Auth : IEndpointV1
{
private static readonly string LOGTAG = Log.LogTagFromType<Auth>();
private const string COOKIE_NAME = "RefreshToken";
private static string GetCookieName(IHttpContextAccessor httpContextAccessor)
=> $"{COOKIE_NAME}_{httpContextAccessor.HttpContext?.Request.Host.Port ?? 0}";
public static void Map(RouteGroupBuilder group)
{
group.MapPost("auth/refresh", async ([FromServices] ILoginProvider loginProvider, [FromServices] JWTConfig jWTConfig, [FromServices] IHttpContextAccessor httpContextAccessor, [FromBody] Dto.RefreshTokenInputDto? input, CancellationToken ct) =>
{
var cookieName = GetCookieName(httpContextAccessor);
if (httpContextAccessor.HttpContext!.Request.Cookies.TryGetValue(cookieName, out var refreshTokenString))
{
try
{
var result = await loginProvider.PerformLoginWithRefreshToken(refreshTokenString, input?.Nonce ?? "", ct);
AddCookie(httpContextAccessor.HttpContext, cookieName, result.RefreshToken, DateTimeOffset.UtcNow.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes));
return new Dto.AccessTokenOutputDto(result.AccessToken, result.Nonce);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RefreshTokenError", ex, "Failed to refresh token");
// Any error with the refresh token should be treated as unauthorized
throw new UnauthorizedException("Failed to refresh token");
}
}
throw new UnauthorizedException("Authorization failed due to missing cookie.");
});
group.MapPost("auth/signin", async ([FromServices] ILoginProvider loginProvider, [FromServices] JWTConfig jWTConfig, [FromServices] IHttpContextAccessor httpContextAccessor, [FromServices] Connection connection, [FromBody] Dto.SigninInputDto input, CancellationToken ct) =>
{
if (connection.ApplicationSettings.DisableSigninTokens)
throw new UnauthorizedException("Signin tokens are disabled");
var cookieName = GetCookieName(httpContextAccessor);
try
{
var result = await loginProvider.PerformLoginWithSigninToken(input.SigninToken, !(input.RememberMe ?? false), ct);
if (!string.IsNullOrWhiteSpace(result.RefreshToken))
AddCookie(httpContextAccessor.HttpContext!, cookieName, result.RefreshToken, DateTimeOffset.UtcNow.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes));
return new Dto.AccessTokenOutputDto(result.AccessToken, result.Nonce);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "SigninTokenError", ex, "Failed to sign in");
// Any error with the signin token should be treated as unauthorized
if (ex is SecurityTokenExpiredException)
throw new UnauthorizedException("Signin token expired");
throw new UnauthorizedException("Failed to sign in");
}
});
group.MapPost("auth/login", async ([FromServices] ILoginProvider loginProvider, [FromServices] JWTConfig jWTConfig, [FromServices] IHttpContextAccessor httpContextAccessor, [FromBody] Dto.LoginInputDto input, CancellationToken ct) =>
{
var cookieName = GetCookieName(httpContextAccessor);
try
{
var result = await loginProvider.PerformLoginWithPassword(input.Password, !(input.RememberMe ?? false), ct);
if (!string.IsNullOrWhiteSpace(result.RefreshToken))
AddCookie(httpContextAccessor.HttpContext!, cookieName, result.RefreshToken, DateTimeOffset.UtcNow.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes));
return new Dto.AccessTokenOutputDto(result.AccessToken, result.Nonce);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "PasswordLoginError", ex, "Failed to log in");
// Any error with the password should be treated as unauthorized
throw new UnauthorizedException("Failed to log in");
}
});
group.MapPost("auth/issuesignintoken", ([FromServices] Connection connection, [FromServices] IJWTTokenProvider tokenProvider, [FromBody] Dto.IssueSigninTokenInputDto input) =>
{
if (connection.ApplicationSettings.DisableSigninTokens)
throw new UnauthorizedException("Signin tokens are disabled");
if (!connection.ApplicationSettings.VerifyWebserverPassword(input.Password))
throw new UnauthorizedException("Incorrect password");
var signinToken = tokenProvider.CreateSigninToken("web-api");
return new Dto.SigninTokenOutputDto(signinToken);
});
group.MapPost("auth/refresh/logout", ([FromServices] ILoginProvider loginProvider, [FromServices] IHttpContextAccessor httpContextAccessor, [FromBody] Dto.RefreshTokenInputDto? input) =>
PerformLogout(loginProvider, httpContextAccessor, input));
group.MapPost("auth/issuetoken/{operation}", ([FromServices] Connection connection, [FromServices] IJWTTokenProvider tokenProvider, [FromRoute] string operation) =>
{
switch (operation)
{
case "export":
case "bugreport":
break;
default:
throw new BadRequestException("Invalid operation");
}
var singleOperationToken = tokenProvider.CreateSingleOperationToken("web-api", operation);
return new Dto.SingleOperationTokenOutputDto(singleOperationToken);
}).RequireAuthorization();
group.MapPost("auth/issue-forever-token", ([FromServices] Connection connection, [FromServices] IJWTTokenProvider tokenProvider) =>
{
var res = connection.ApplicationSettings.ConsumeForeverToken();
if (res == null)
throw new UnauthorizedException("Forever tokens are not enabled");
if (!res.Value)
throw new UnauthorizedException("Cannot generate multiple forever tokens, restart the server to generate a new one");
return new Dto.AccessTokenOutputDto(tokenProvider.CreateForeverToken(), null);
}).RequireAuthorization();
}
private static void AddCookie(HttpContext context, string name, string value, DateTimeOffset expires)
=> context.Response.Cookies.Append(name, value, new CookieOptions
{
Expires = expires,
Path = "/api/v1/auth/refresh",
Secure = context.Request.IsHttps,
HttpOnly = true,
SameSite = SameSiteMode.Strict,
IsEssential = true,
Domain = context.Request.Host.Host
});
private static object PerformLogout(ILoginProvider loginProvider, IHttpContextAccessor httpContextAccessor, Dto.RefreshTokenInputDto? input)
{
var cookieName = GetCookieName(httpContextAccessor);
if (httpContextAccessor.HttpContext!.Request.Cookies.TryGetValue(cookieName, out var refreshTokenString))
{
try
{
loginProvider.PerformLogoutWithRefreshToken(refreshTokenString, input?.Nonce, CancellationToken.None);
}
catch
{
// Ignore invalid refresh tokens
}
}
// Also remove the cookie, in case we failed to delete it
httpContextAccessor.HttpContext!.Response.Cookies.Delete(cookieName);
return new { success = true };
}
}