duplicati/Duplicati/WebserverCore/Middlewares/JWTProvider.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

257 lines
11 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 System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Duplicati.Server;
using Duplicati.WebserverCore.Abstractions;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
namespace Duplicati.WebserverCore.Middlewares;
public record JWTConfig
{
public required string Authority { get; init; }
public required string Audience { get; init; }
public required string SigningKey { get; init; }
public int AccessTokenDurationInMinutes { get; init; } = 15;
public int RefreshTokenDurationInMinutes { get; init; } = 60 * 24 * 30;
public int RefreshTokenShortLivedDurationInMinutes { get; init; } = 60 * 2;
public int SigninTokenDurationInMinutes { get; init; } = 5;
public int SingleOperationTokenDurationInMinutes { get; init; } = 1;
public int MaxRefreshTokenDrift { get; init; } = 1;
public int MaxRefreshTokenDriftSeconds { get; init; } = 30;
public bool RequireRefreshNonce { get; init; } = true;
public SymmetricSecurityKey SymmetricSecurityKey() => new(Encoding.UTF8.GetBytes(SigningKey));
public PbkdfConfig PbkdfConfig { get; init; } = PbkdfConfig.Default;
public static JWTConfig Create() => new()
{
Authority = "https://duplicati",
Audience = "https://duplicati",
SigningKey = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)),
};
}
public class JWTTokenProvider(JWTConfig jWTConfig) : IJWTTokenProvider
{
private const string TemporaryFamilyId = "temporary";
private const string ForeverTokenUserId = "forever-token";
public string CreateSingleOperationToken(string userId, string operation)
=> GenerateToken([
new Claim(Claims.Type, TokenType.SingleOperationToken.ToString()),
new Claim(Claims.UserId, userId),
new Claim(Claims.Operation, operation)
], DateTime.Now, expires: DateTime.Now.AddMinutes(jWTConfig.SingleOperationTokenDurationInMinutes));
public string CreateSigninToken(string userId)
=> GenerateToken([
new Claim(Claims.Type, TokenType.SigninToken.ToString()),
new Claim(Claims.UserId, userId)
], DateTime.Now, expires: DateTime.Now.AddMinutes(jWTConfig.SigninTokenDurationInMinutes));
public string CreateAccessToken(string userId, string tokenFamilyId, TimeSpan? expiration = null)
=> GenerateToken([
new Claim(Claims.Type, TokenType.AccessToken.ToString()),
new Claim(Claims.UserId, userId),
new Claim(Claims.Family, tokenFamilyId)
], DateTime.Now, expires: DateTime.Now.AddMinutes(Math.Min(jWTConfig.AccessTokenDurationInMinutes, expiration?.TotalMinutes ?? jWTConfig.AccessTokenDurationInMinutes)));
public string CreateForeverToken()
=> GenerateToken([
new Claim(Claims.Type, TokenType.AccessToken.ToString()),
new Claim(Claims.UserId, ForeverTokenUserId),
new Claim(Claims.Family, TemporaryFamilyId)
], DateTime.Now, expires: DateTime.Now.AddYears(10));
public (string RefreshToken, string? Nonce) CreateRefreshToken(string userId, string tokenFamilyId, int counter, bool shortLived)
{
var claims = new List<Claim>
{
new Claim(Claims.Type, TokenType.RefreshToken.ToString()),
new Claim(Claims.UserId, userId),
new Claim(Claims.Family, tokenFamilyId),
new Claim(Claims.Counter, counter.ToString()),
new Claim(Claims.IssuedAt, (DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds.ToString())
};
string? nonce = null;
if (shortLived || jWTConfig.RequireRefreshNonce)
{
nonce = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
claims.Add(new Claim(Claims.Nonce, jWTConfig.PbkdfConfig.WithPassword(nonce).Hash));
}
var refreshToken = GenerateToken(claims, DateTime.Now, expires: DateTime.Now.AddMinutes(shortLived ? jWTConfig.RefreshTokenShortLivedDurationInMinutes : jWTConfig.RefreshTokenDurationInMinutes));
return (refreshToken, nonce);
}
private string GenerateToken(IEnumerable<Claim> claims, DateTime notBefore, DateTime expires)
{
var creds = new SigningCredentials(jWTConfig.SymmetricSecurityKey(), SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(jWTConfig.Authority, jWTConfig.Audience, claims, notBefore: notBefore, expires: expires, signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public IJWTTokenProvider.SingleOperationToken ReadSingleOperationToken(string token)
{
var jwtToken = ParseAndValidateToken(token, TokenType.SingleOperationToken);
return new IJWTTokenProvider.SingleOperationToken(
jwtToken.ValidFrom,
jwtToken.ValidTo,
jwtToken.Claims.First(c => c.Type == Claims.UserId).Value,
jwtToken.Claims.First(c => c.Type == Claims.Operation).Value
);
}
public IJWTTokenProvider.SigninToken ReadSigninToken(string token)
{
var jwtToken = ParseAndValidateToken(token, TokenType.SigninToken);
return new IJWTTokenProvider.SigninToken(
jwtToken.ValidFrom,
jwtToken.ValidTo,
jwtToken.Claims.First(c => c.Type == Claims.UserId).Value
);
}
public IJWTTokenProvider.AccessToken ReadAccessToken(string token)
{
var jwtToken = ParseAndValidateToken(token, TokenType.AccessToken);
return new IJWTTokenProvider.AccessToken(
jwtToken.ValidFrom,
jwtToken.ValidTo,
jwtToken.Claims.First(c => c.Type == Claims.Family).Value,
jwtToken.Claims.First(c => c.Type == Claims.UserId).Value
);
}
public IJWTTokenProvider.RefreshToken ReadRefreshToken(string token, string? nonce)
{
// We rely on the fact that the caller cannot remove or modify the nonce after creation.
// If the token was created without a nonce, it will be accepted even with a mismatched nonce.
var jwtToken = ParseAndValidateToken(token, TokenType.RefreshToken);
var validateNonce = jwtToken.Claims.FirstOrDefault(c => c.Type == Claims.Nonce);
if (validateNonce != null)
{
var nonceClaim = validateNonce.Value;
var pbkdf = jWTConfig.PbkdfConfig.WithHash(nonceClaim);
if (!pbkdf.VerifyPassword(nonce ?? ""))
throw new SecurityTokenValidationException("Refresh nonce does not match the expected value");
}
return new IJWTTokenProvider.RefreshToken(
jwtToken.ValidFrom,
jwtToken.ValidTo,
jwtToken.Claims.First(c => c.Type == Claims.Family).Value,
jwtToken.Claims.First(c => c.Type == Claims.UserId).Value,
int.Parse(jwtToken.Claims.First(c => c.Type == Claims.Counter).Value)
);
}
private JwtSecurityToken ParseAndValidateToken(string token, TokenType tokenType)
{
new JwtSecurityTokenHandler().ValidateToken(token, GetTokenValidationParameters(jWTConfig), out var securityToken);
if (securityToken is JwtSecurityToken jwtToken && jwtToken.Claims.First(c => c.Type == Claims.Type).Value == tokenType.ToString())
return jwtToken;
throw new SecurityTokenValidationException("Invalid token type");
}
public static TokenValidationParameters GetTokenValidationParameters(JWTConfig jWTConfig)
{
return new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
RequireExpirationTime = true,
RequireSignedTokens = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jWTConfig.Authority,
ValidAudience = jWTConfig.Audience,
IssuerSigningKey = jWTConfig.SymmetricSecurityKey(),
ClockSkew = TimeSpan.FromSeconds(5)
};
}
public static async Task ValidateAccessToken(TokenValidatedContext context, ITokenFamilyStore store)
{
var tokenHandler = new JwtSecurityTokenHandler();
var jwtToken = context.SecurityToken as JsonWebToken ?? throw new Exception("Invalid token");
var tokenTypeClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == Claims.Type);
if (tokenTypeClaim == null || tokenTypeClaim.Value != TokenType.AccessToken.ToString())
{
context.Fail("Invalid token type.");
return;
}
var tokenFamilyClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == Claims.Family);
if (tokenFamilyClaim == null || string.IsNullOrEmpty(tokenFamilyClaim.Value))
{
context.Fail("Invalid token.");
return;
}
var userIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == Claims.UserId);
if (userIdClaim == null || string.IsNullOrEmpty(userIdClaim.Value))
{
context.Fail("Invalid token.");
return;
}
if (tokenFamilyClaim.Value != TemporaryFamilyId)
{
var tokenFamily = await store.GetTokenFamily(userIdClaim.Value, tokenFamilyClaim.Value, context.HttpContext.RequestAborted);
if (tokenFamily == null)
{
context.Fail("Invalid token.");
return;
}
}
}
string IJWTTokenProvider.TemporaryFamilyId => TemporaryFamilyId;
private enum TokenType
{
AccessToken,
RefreshToken,
SigninToken,
SingleOperationToken
}
private static class Claims
{
public const string Type = "typ";
public const string UserId = "sid";
public const string Family = "fam";
public const string Counter = "cnt";
public const string IssuedAt = "iat";
public const string Operation = "sop";
public const string Nonce = "nce";
}
}