Skip to content

Commit a3e747e

Browse files
author
Timothy Dodd
committed
Update authentication and Angular components
- Updated package references in `LogMkAgent.csproj` and `LogMkApi.csproj` to version 9.0.6 for various Microsoft libraries. - Enhanced `AuthController` with new methods for login, password change, token refresh, and revocation, including model validation and error handling. - Introduced new request and response models for authentication processes. - Updated `LogController` to include model validation and rate limiting for log creation. - Modified `DatabaseInitializer.cs` to create a `RefreshToken` table. - Enhanced CORS policy and added rate limiting middleware in `Program.cs`. - Updated Angular dependencies to newer versions and removed deprecated packages. - Refactored `user-menu` component for improved structure and accessibility. - Introduced a custom dropdown component and improved styling across various components.
1 parent e5837e7 commit a3e747e

55 files changed

Lines changed: 5929 additions & 1388 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 407 additions & 0 deletions
Large diffs are not rendered by default.

src/LogMkAgent/LogMkAgent.csproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
</PropertyGroup>
1414

1515
<ItemGroup>
16-
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5" />
17-
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" />
18-
<PackageReference Include="System.Text.Json" Version="9.0.5" />
16+
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
17+
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
18+
<PackageReference Include="System.Text.Json" Version="9.0.6" />
1919
</ItemGroup>
2020

2121
<ItemGroup>
Lines changed: 158 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
using System.Security.Claims;
1+
using System.ComponentModel.DataAnnotations;
2+
using System.Security.Claims;
23
using LogMkApi.Data.Models;
34
using LogMkApi.Services;
45
using Microsoft.AspNetCore.Authorization;
56
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.AspNetCore.RateLimiting;
68
using ServiceStack.Data;
79
using ServiceStack.OrmLite;
810
using ServiceStack.OrmLite.Dapper;
@@ -16,29 +18,53 @@ public class AuthController : Controller
1618
private readonly IDbConnectionFactory _dbFactory;
1719
private readonly AuthService _authService;
1820
private readonly PasswordService _passwordService;
21+
private readonly RefreshTokenService _refreshTokenService;
22+
private readonly IConfiguration _configuration;
1923

20-
21-
public AuthController(IDbConnectionFactory dbFactory, AuthService authService, PasswordService passwordService)
24+
public AuthController(IDbConnectionFactory dbFactory, AuthService authService, PasswordService passwordService, RefreshTokenService refreshTokenService, IConfiguration configuration)
2225
{
2326
_dbFactory = dbFactory;
2427
_authService = authService;
2528
_passwordService = passwordService;
29+
_refreshTokenService = refreshTokenService;
30+
_configuration = configuration;
2631
}
2732
[AllowAnonymous]
33+
[EnableRateLimiting("AuthPolicy")]
2834
[HttpPost("login")]
2935
public async Task<IActionResult> Login([FromBody] LoginRequest request)
3036
{
31-
using (var db = _dbFactory.OpenDbConnection())
37+
if (!ModelState.IsValid)
38+
{
39+
return BadRequest(ModelState);
40+
}
41+
42+
try
3243
{
33-
// In a real-world scenario, you would retrieve the user from a database
34-
var user = (await db.QueryAsync<User>("SELECT * FROM User WHERE UserName = @UserName", new { UserName = request.UserName })).FirstOrDefault();
35-
if (user == null || !_authService.ValidateUser(user, request.Password))
44+
using (var db = _dbFactory.OpenDbConnection())
3645
{
37-
return Unauthorized("Invalid email or password.");
38-
}
46+
// In a real-world scenario, you would retrieve the user from a database
47+
var user = (await db.QueryAsync<User>("SELECT * FROM User WHERE UserName = @UserName", new { UserName = request.UserName })).FirstOrDefault();
48+
if (user == null || !_authService.ValidateUser(user, request.Password))
49+
{
50+
return Unauthorized(new { Error = "Invalid username or password" });
51+
}
52+
53+
var token = _authService.GenerateJwtToken(user);
54+
var refreshToken = await _refreshTokenService.CreateRefreshTokenAsync(user.Id);
3955

40-
var token = _authService.GenerateJwtToken(user);
41-
return Ok(new { Token = token });
56+
return Ok(new LoginResponse
57+
{
58+
AccessToken = token,
59+
RefreshToken = refreshToken.Token,
60+
ExpiresIn = _configuration.GetValue<int>("JwtSettings:ExpiryMinutes", 30) * 60
61+
});
62+
}
63+
}
64+
catch (Exception ex)
65+
{
66+
// Log the exception but don't expose internal details
67+
return StatusCode(500, new { Error = "An error occurred during authentication" });
4268
}
4369
}
4470
[Authorize]
@@ -63,44 +89,153 @@ public async Task<IActionResult> GetUser()
6389
}
6490
}
6591
[Authorize]
92+
[EnableRateLimiting("AuthPolicy")]
6693
[HttpPost("change-password")]
6794
public async Task<IActionResult> ChangePasswordAsync([FromBody] ChangePasswordRequest request)
6895
{
96+
if (!ModelState.IsValid)
97+
{
98+
return BadRequest(ModelState);
99+
}
100+
69101
var userName = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;
70102
if (userName == null)
71103
{
72-
return Unauthorized();
104+
return Unauthorized(new { Error = "User not authenticated" });
73105
}
74-
using (var db = _dbFactory.OpenDbConnection())
106+
107+
try
75108
{
76-
var user = (await db.QueryAsync<User>("SELECT * FROM User WHERE UserName = @UserName", new { UserName = userName })).FirstOrDefault();
77-
if (user == null)
78-
{
79-
return NotFound();
80-
}
81-
if (!_authService.ValidateUser(user, request.OldPassword))
109+
using (var db = _dbFactory.OpenDbConnection())
82110
{
83-
return Unauthorized("Invalid password.");
111+
var user = (await db.QueryAsync<User>("SELECT * FROM User WHERE UserName = @UserName", new { UserName = userName })).FirstOrDefault();
112+
if (user == null)
113+
{
114+
return NotFound(new { Error = "User not found" });
115+
}
116+
if (!_authService.ValidateUser(user, request.OldPassword))
117+
{
118+
return Unauthorized(new { Error = "Current password is incorrect" });
119+
}
120+
user.PasswordHash = _passwordService.HashPassword(user, request.NewPassword);
121+
await db.UpdateAsync(user);
122+
return Ok(new { Message = "Password changed successfully" });
84123
}
85-
user.PasswordHash = _passwordService.HashPassword(user, request.NewPassword);
86-
await db.UpdateAsync(user);
87-
return Ok();
88124
}
125+
catch (Exception ex)
126+
{
127+
// Log the exception but don't expose internal details
128+
return StatusCode(500, new { Error = "An error occurred while changing the password" });
129+
}
130+
}
131+
132+
[AllowAnonymous]
133+
[HttpPost("refresh")]
134+
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
135+
{
136+
var principal = _authService.GetPrincipalFromExpiredToken(request.AccessToken);
137+
if (principal == null)
138+
{
139+
return BadRequest(new { Error = "Invalid access token" });
140+
}
141+
142+
var userId = _authService.GetUserIdFromPrincipal(principal);
143+
if (!userId.HasValue)
144+
{
145+
return BadRequest(new { Error = "Invalid access token" });
146+
}
147+
148+
if (!await _refreshTokenService.ValidateRefreshTokenAsync(request.RefreshToken))
149+
{
150+
return Unauthorized(new { Error = "Invalid refresh token" });
151+
}
152+
153+
using var db = _dbFactory.OpenDbConnection();
154+
var user = (await db.SelectAsync<User>(u => u.Id == userId.Value)).FirstOrDefault();
155+
if (user == null)
156+
{
157+
return NotFound(new { Error = "User not found" });
158+
}
159+
160+
// Rotate refresh token
161+
var newRefreshToken = await _refreshTokenService.RotateRefreshTokenAsync(request.RefreshToken, user.Id);
162+
var newAccessToken = _authService.GenerateJwtToken(user);
163+
164+
return Ok(new LoginResponse
165+
{
166+
AccessToken = newAccessToken,
167+
RefreshToken = newRefreshToken.Token,
168+
ExpiresIn = _configuration.GetValue<int>("JwtSettings:ExpiryMinutes", 30) * 60
169+
});
170+
}
171+
172+
[HttpPost("revoke")]
173+
public async Task<IActionResult> RevokeToken([FromBody] RevokeTokenRequest request)
174+
{
175+
await _refreshTokenService.RevokeRefreshTokenAsync(request.RefreshToken);
176+
return Ok(new { Message = "Token revoked successfully" });
177+
}
178+
179+
[HttpPost("logout")]
180+
public async Task<IActionResult> Logout()
181+
{
182+
var userId = _authService.GetUserIdFromPrincipal(User);
183+
if (userId.HasValue)
184+
{
185+
await _refreshTokenService.RevokeAllUserRefreshTokensAsync(userId.Value);
186+
}
187+
return Ok(new { Message = "Logged out successfully" });
89188
}
90189

91190
}
92191
public class ChangePasswordRequest
93192
{
193+
[Required(ErrorMessage = "Old password is required")]
194+
[StringLength(256, MinimumLength = 1, ErrorMessage = "Old password must be between 1 and 256 characters")]
94195
public required string OldPassword { get; set; }
196+
197+
[Required(ErrorMessage = "New password is required")]
198+
[StringLength(256, MinimumLength = 8, ErrorMessage = "New password must be between 8 and 256 characters")]
199+
[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]",
200+
ErrorMessage = "New password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character")]
95201
public required string NewPassword { get; set; }
96202
}
203+
97204
public class LoginRequest
98205
{
206+
[Required(ErrorMessage = "Username is required")]
207+
[StringLength(100, MinimumLength = 3, ErrorMessage = "Username must be between 3 and 100 characters")]
208+
[RegularExpression(@"^[a-zA-Z0-9_@.-]+$", ErrorMessage = "Username can only contain letters, numbers, underscores, periods, hyphens, and @ symbols")]
99209
public required string UserName { get; set; }
210+
211+
[Required(ErrorMessage = "Password is required")]
212+
[StringLength(256, MinimumLength = 1, ErrorMessage = "Password must be between 1 and 256 characters")]
100213
public required string Password { get; set; }
101214
}
102215
public class UserResponse
103216
{
104217
public Guid Id { get; set; }
105218
public required string UserName { get; set; }
106219
}
220+
221+
public class LoginResponse
222+
{
223+
public required string AccessToken { get; set; }
224+
public required string RefreshToken { get; set; }
225+
public int ExpiresIn { get; set; }
226+
}
227+
228+
public class RefreshTokenRequest
229+
{
230+
[Required(ErrorMessage = "Access token is required")]
231+
public required string AccessToken { get; set; }
232+
233+
[Required(ErrorMessage = "Refresh token is required")]
234+
public required string RefreshToken { get; set; }
235+
}
236+
237+
public class RevokeTokenRequest
238+
{
239+
[Required(ErrorMessage = "Refresh token is required")]
240+
public required string RefreshToken { get; set; }
241+
}

src/LogMkApi/Controllers/LogController.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
using LogMkCommon;
77
using Microsoft.AspNetCore.Authorization;
88
using Microsoft.AspNetCore.Mvc;
9+
using Microsoft.AspNetCore.RateLimiting;
910
using ServiceStack;
1011

1112
namespace LogMkApi.Controllers;
1213

1314
[Authorize]
15+
[EnableRateLimiting("ApiPolicy")]
1416
[ApiController]
1517
[Microsoft.AspNetCore.Mvc.Route("api/log")]
1618
public class LogController : ControllerBase
@@ -47,6 +49,24 @@ public async Task<ActionResult<LogResponse>> Create(
4749
{
4850
return BadRequest(new { Error = "No log lines provided" });
4951
}
52+
53+
// Check for basic model validation errors
54+
if (!ModelState.IsValid)
55+
{
56+
var validationErrors = ModelState
57+
.Where(x => x.Value?.Errors.Count > 0)
58+
.ToDictionary(
59+
kvp => kvp.Key,
60+
kvp => kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray() ?? Array.Empty<string>()
61+
);
62+
return BadRequest(new { Error = "Validation failed", Errors = validationErrors });
63+
}
64+
65+
// Additional rate limiting for large batches
66+
if (logLines.Count > 1000)
67+
{
68+
return BadRequest(new { Error = "Batch size cannot exceed 1000 log lines" });
69+
}
5070

5171
var batchId = Guid.NewGuid().ToString("N")[..8];
5272
var receivedAt = DateTimeOffset.UtcNow;

src/LogMkApi/Data/DatabaseInitializer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public void CreateTable()
2323
db.CreateTableIfNotExists<Log>();
2424
db.CreateTableIfNotExists<LogSummary>();
2525
db.CreateTableIfNotExists<LogSummaryHour>();
26+
db.CreateTableIfNotExists<RefreshToken>();
2627
if (db.CreateTableIfNotExists<User>())
2728
{
2829
var user = new User
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using ServiceStack.DataAnnotations;
2+
3+
namespace LogMkApi.Data.Models;
4+
5+
public class RefreshToken
6+
{
7+
[PrimaryKey]
8+
[AutoIncrement]
9+
public int Id { get; set; }
10+
11+
[Required]
12+
[StringLength(255)]
13+
public required string Token { get; set; }
14+
15+
[Required]
16+
[ForeignKey(typeof(User), OnDelete = "CASCADE")]
17+
public required Guid UserId { get; set; }
18+
19+
public DateTime ExpiryDate { get; set; }
20+
public bool IsRevoked { get; set; }
21+
public DateTime CreatedDate { get; set; }
22+
}

src/LogMkApi/LogMkApi.csproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414

1515
<ItemGroup>
1616

17-
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" />
18-
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.5" />
17+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
18+
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.6" />
1919
<PackageReference Include="MySql.Data" Version="9.3.0" />
2020
<PackageReference Include="ServiceStack.OrmLite.MySql" Version="8.8.0" />
21-
<PackageReference Include="System.Text.Json" Version="9.0.5" />
21+
<PackageReference Include="System.Text.Json" Version="9.0.6" />
2222
<PackageReference Include="Microsoft.AspNetCore.SpaProxy">
23-
<Version>9.0.5</Version>
23+
<Version>9.0.6</Version>
2424
</PackageReference>
2525
</ItemGroup>
2626
<ItemGroup>

0 commit comments

Comments
 (0)