diff --git a/Database/Models/User.cs b/Database/Models/User.cs index 328271c..3ff11a0 100644 --- a/Database/Models/User.cs +++ b/Database/Models/User.cs @@ -13,4 +13,6 @@ public class User public string Phone { get; set; } = null!; [Required] public string Password { get; set; } = null!; + [Required] + public DateTime PasswordChangeDate { get; set; } = DateTime.UtcNow; } \ No newline at end of file diff --git a/Program.cs b/Program.cs index 563687d..7dcc4a2 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,10 @@ + using Serilog; using Serilog.Exceptions; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + var builder = WebApplication.CreateBuilder(args); @@ -17,6 +22,28 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } +// Authorization +builder.Services.AddAuthorization(); +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + + string issuer = Environment.GetEnvironmentVariable("JWT_ISSUER") ?? "renis"; + string audience = Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? "renis"; + string secret = Environment.GetEnvironmentVariable("JWT_SECRET") ?? "TopSecretKeyForTheProtectionOfChocolateCookiesAndOtherSweetThings"; + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = issuer, + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)), + ValidateIssuerSigningKey = true + }; + }); + app.UseHttpsRedirection(); app.Run(); diff --git a/Services/Jwt/IJwtService.cs b/Services/Jwt/IJwtService.cs new file mode 100644 index 0000000..da77a67 --- /dev/null +++ b/Services/Jwt/IJwtService.cs @@ -0,0 +1,11 @@ +using Renis.Database.Models; + +namespace Renis.Services.Jwt; + +public interface IJwtService +{ + string GenerateAccessToken(User user); + string GenerateRefreshToken(User user); + Tuple ValidateAccessToken(string? token); + Task> ValidateRefreshToken(string? token); +} diff --git a/Services/Jwt/JwtService.cs b/Services/Jwt/JwtService.cs new file mode 100644 index 0000000..cdcecbb --- /dev/null +++ b/Services/Jwt/JwtService.cs @@ -0,0 +1,201 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Renis.Database.Models; +using Microsoft.IdentityModel.Tokens; +using Renis.Repositories; + +namespace Renis.Services.Jwt; + +public class JwtService : IJwtService +{ + private readonly string _secretKey; + private readonly string _issuer; + private readonly string _audience; + private readonly IUserRepository _userRepo; + private readonly ILogger _logger; + public JwtService(ILogger logger, IUserRepository userRepo) + { + _logger = logger; + _userRepo = userRepo; + + // TODO: Change issuer & audience + _issuer = Environment.GetEnvironmentVariable("AUTH_JWT_ISSUER") ?? "renis"; + _audience = Environment.GetEnvironmentVariable("AUTH_JWT_AUDIENCE") ?? "renis"; + + _secretKey = Environment.GetEnvironmentVariable("AUTH_JWT_SECRET")!; + if (_secretKey == null) + { + throw new Exception("Missing AUTH_JWT_SECRET environment variable"); + } + } + + /// + /// Generates an access token for the specified user. + /// + /// The user for whom the access token is generated. + /// The generated access token as a string. + public string GenerateAccessToken(User user) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Convert.FromBase64String(_secretKey); + + + var tokenDescriptor = new SecurityTokenDescriptor + { + Issuer = _issuer, + Audience = _audience, + Subject = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Name, user.Phone), + new Claim(ClaimsIdentity.DefaultRoleClaimType,"User"), + new Claim(ClaimTypes.AuthenticationMethod, "Access") + }), + Expires = DateTime.UtcNow.AddMinutes(50), + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + /// + /// Generates a refresh token for the specified user. + /// + /// The user for whom the refresh token is generated. + /// The generated refresh token as a string. + public string GenerateRefreshToken(User user) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Convert.FromBase64String(_secretKey); + var tokenDescriptor = new SecurityTokenDescriptor + { + Issuer = _issuer, + Audience = _audience, + Subject = new ClaimsIdentity(new[] + { + new Claim("PasswordChangeDate", user.PasswordChangeDate.ToString()), + new Claim(ClaimTypes.Name, user.Phone), + new Claim(ClaimsIdentity.DefaultRoleClaimType,"User"), + new Claim(ClaimTypes.AuthenticationMethod, "Refresh") + }), + Expires = DateTime.UtcNow.AddDays(7), + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + /// + /// Validates the access token and extracts the username from it if valid. + /// Returns a tuple indicating whether the token is valid and the extracted username. + /// + /// The access token to validate. + /// A tuple indicating whether the token is valid and the extracted username. + public Tuple ValidateAccessToken(string? token) + { + try + { + if (token == null) + { + _logger.LogWarning("JwtService: No access token string provided"); + return new (false, ""); + } + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Convert.FromBase64String(_secretKey); + + TokenValidationParameters validationParameters = new() { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = true, + ValidIssuer = _issuer, + ValidateAudience = true, + ValidAudience = _audience, + ClockSkew = TimeSpan.Zero + }; + + // Валидация токена + tokenHandler.ValidateToken(token, validationParameters, out SecurityToken validatedToken); + JwtSecurityToken validatedJwt = (JwtSecurityToken)validatedToken; + + var username = validatedJwt.Claims.First(claim => claim.Type == "unique_name").Value; + + // Проверка типа токена + if (validatedJwt.Claims.First(claim => claim.Type == ClaimTypes.AuthenticationMethod).Value != "Access") + { + _logger.LogWarning("JwtService: the token was not a access token"); + return new (false, ""); + } + + return new (true, username); + } + catch (Exception e) + { + _logger.LogWarning("JwtService: Token invalidated due to exception:\n{Exception}", e); + return new (false, "");; + } + } + + /// + /// Validates the refresh token and extracts the username from it if valid. + /// Returns a tuple indicating whether the token is valid and the extracted username. + /// + /// The refresh token to validate. + /// A tuple indicating whether the token is valid and the extracted username. + public async Task> ValidateRefreshToken(string? token) + { + try + { + if (token == null) + { + _logger.LogWarning("JwtService: No refresh token string provided"); + return new (false, "");; + } + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Convert.FromBase64String(_secretKey); + + TokenValidationParameters validationParameters = new() { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = true, + ValidIssuer = _issuer, + ValidateAudience = true, + ValidAudience = _audience, + ClockSkew = TimeSpan.Zero + }; + + // Валидация токена + tokenHandler.ValidateToken(token, validationParameters, out SecurityToken validatedToken); + JwtSecurityToken validatedJwt = (JwtSecurityToken)validatedToken; + + var passwordChangeDate = validatedJwt.Claims.First(claim => claim.Type == "PasswordChangeDate").Value; + var username = validatedJwt.Claims.First(claim => claim.Type == "unique_name").Value; + + // Проверка типа токена + if (validatedJwt.Claims.First(claim => claim.Type == "authmethod").Value != "Refresh") + { + _logger.LogWarning("JwtService: the token was not a refresh token"); + return new (false, ""); + } + + var user = await _userRepo.GetUserByPhone(username); + if (user == null) + { + return new (false, ""); + } + + // Проверка, что дата изменения пароля совпадает с фактической + if (user.PasswordChangeDate.ToString() != passwordChangeDate) + { + username=null; + _logger.LogWarning("JwtService: the password change date was not equal to the one in the token\n1. {1}\n2. {2}", passwordChangeDate, user.PasswordChangeDate.ToString()); + return new (false, ""); + } + + return new (true, username); + } + catch (Exception e) + { + _logger.LogWarning("JwtService: Token invalidated due to exception:\n{Exception}", e); + return new (false, ""); + } + } +} \ No newline at end of file diff --git a/renis-backend.csproj b/renis-backend.csproj index de0f201..2ef8b6f 100644 --- a/renis-backend.csproj +++ b/renis-backend.csproj @@ -14,16 +14,17 @@ - - - + + + - - - - - + + + + + +