Skip to content

Commit 3413e80

Browse files
authored
Merge pull request #813 from Chris0Jeky/feature/sso-oidc-mfa-policy
SEC-07: SSO/OIDC integration with optional MFA policy
2 parents b6ad7b5 + 3689c24 commit 3413e80

48 files changed

Lines changed: 4737 additions & 27 deletions

File tree

Some content is hidden

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

backend/src/Taskdeck.Api/Controllers/AuthController.cs

Lines changed: 180 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
namespace Taskdeck.Api.Controllers;
1919

20-
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
20+
public record ChangePasswordRequest(string CurrentPassword, string NewPassword, string? MfaCode = null);
2121
public record ExchangeCodeRequest(string Code);
2222
public record LinkExchangeRequest(string Code);
2323

@@ -32,13 +32,23 @@ public class AuthController : AuthenticatedControllerBase
3232
{
3333
private readonly AuthenticationService _authService;
3434
private readonly GitHubOAuthSettings _gitHubOAuthSettings;
35+
private readonly OidcSettings _oidcSettings;
36+
private readonly MfaService _mfaService;
3537
private readonly IUnitOfWork _unitOfWork;
3638

37-
public AuthController(AuthenticationService authService, GitHubOAuthSettings gitHubOAuthSettings, IUserContext userContext, IUnitOfWork unitOfWork)
39+
public AuthController(
40+
AuthenticationService authService,
41+
GitHubOAuthSettings gitHubOAuthSettings,
42+
OidcSettings oidcSettings,
43+
MfaService mfaService,
44+
IUserContext userContext,
45+
IUnitOfWork unitOfWork)
3846
: base(userContext)
3947
{
4048
_authService = authService;
4149
_gitHubOAuthSettings = gitHubOAuthSettings;
50+
_oidcSettings = oidcSettings;
51+
_mfaService = mfaService;
4252
_unitOfWork = unitOfWork;
4353
}
4454

@@ -93,24 +103,40 @@ public async Task<IActionResult> Register([FromBody] CreateUserDto dto)
93103
/// <summary>
94104
/// Change the password for the authenticated caller.
95105
/// The target user is always derived from the JWT — client-supplied user IDs are not accepted.
106+
/// When MFA is enabled and RequireMfaForSensitiveActions is true, a valid MFA code is required.
96107
/// </summary>
97-
/// <param name="request">Current and new password.</param>
108+
/// <param name="request">Current password, new password, and optional MFA code.</param>
98109
/// <response code="204">Password changed successfully.</response>
99110
/// <response code="400">Validation error.</response>
100111
/// <response code="401">Not authenticated or current password is incorrect.</response>
112+
/// <response code="403">MFA verification required but not provided or invalid.</response>
101113
/// <response code="429">Rate limit exceeded.</response>
102114
[HttpPost("change-password")]
103115
[Authorize]
104116
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
105117
[ProducesResponseType(StatusCodes.Status204NoContent)]
106118
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
107119
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
120+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
108121
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
109122
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
110123
{
111124
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
112125
return errorResult!;
113126

127+
// Enforce MFA for sensitive actions when policy requires it
128+
if (await _mfaService.IsMfaRequiredForSensitiveActionAsync(callerUserId))
129+
{
130+
if (string.IsNullOrWhiteSpace(request.MfaCode))
131+
return StatusCode(StatusCodes.Status403Forbidden, new ApiErrorResponse(
132+
ErrorCodes.Forbidden, "MFA verification is required for this action"));
133+
134+
var mfaResult = await _mfaService.VerifyCodeAsync(callerUserId, request.MfaCode);
135+
if (!mfaResult.IsSuccess)
136+
return StatusCode(StatusCodes.Status403Forbidden, new ApiErrorResponse(
137+
ErrorCodes.AuthenticationFailed, "Invalid MFA verification code"));
138+
}
139+
114140
var result = await _authService.ChangePasswordAsync(callerUserId, request.CurrentPassword, request.NewPassword);
115141
return result.IsSuccess ? NoContent() : result.ToErrorActionResult();
116142
}
@@ -447,17 +473,166 @@ public async Task<IActionResult> GetLinkedAccounts()
447473
}
448474

449475
/// <summary>
450-
/// Returns whether GitHub OAuth login is available on this instance.
476+
/// Returns available authentication providers on this instance.
451477
/// </summary>
452478
[HttpGet("providers")]
453479
public IActionResult GetProviders()
454480
{
481+
var oidcProviders = _oidcSettings.ConfiguredProviders
482+
.Select(p => new OidcProviderInfoDto(p.Name, p.DisplayName))
483+
.ToList();
484+
455485
return Ok(new
456486
{
457-
GitHub = _gitHubOAuthSettings.IsConfigured
487+
GitHub = _gitHubOAuthSettings.IsConfigured,
488+
Oidc = oidcProviders
458489
});
459490
}
460491

492+
/// <summary>
493+
/// Initiates OIDC login flow for a named provider. Only available when the provider is configured.
494+
/// </summary>
495+
[HttpGet("oidc/{providerName}/login")]
496+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
497+
public IActionResult OidcLogin(string providerName, [FromQuery] string? returnUrl = null)
498+
{
499+
var provider = _oidcSettings.ConfiguredProviders
500+
.FirstOrDefault(p => string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase));
501+
502+
if (provider == null)
503+
return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, $"OIDC provider '{providerName}' is not configured"));
504+
505+
if (!string.IsNullOrWhiteSpace(returnUrl) && !Url.IsLocalUrl(returnUrl))
506+
return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Invalid return URL"));
507+
508+
var schemeName = $"Oidc_{provider.Name}";
509+
var properties = new AuthenticationProperties
510+
{
511+
RedirectUri = Url.Action(nameof(OidcCallback), new { providerName = provider.Name, returnUrl }),
512+
Items = { { "LoginProvider", provider.Name } }
513+
};
514+
515+
return Challenge(properties, schemeName);
516+
}
517+
518+
/// <summary>
519+
/// Handles the OIDC callback, creates/links the user, and redirects with a short-lived code.
520+
/// </summary>
521+
[HttpGet("oidc/{providerName}/callback")]
522+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
523+
public async Task<IActionResult> OidcCallback(string providerName, [FromQuery] string? returnUrl = null)
524+
{
525+
var provider = _oidcSettings.ConfiguredProviders
526+
.FirstOrDefault(p => string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase));
527+
528+
if (provider == null)
529+
return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, $"OIDC provider '{providerName}' is not configured"));
530+
531+
var schemeName = $"Oidc_{provider.Name}";
532+
var authenticateResult = await HttpContext.AuthenticateAsync(schemeName);
533+
if (!authenticateResult.Succeeded || authenticateResult.Principal == null)
534+
{
535+
return Unauthorized(new ApiErrorResponse(
536+
ErrorCodes.AuthenticationFailed,
537+
$"OIDC authentication with '{provider.DisplayName}' failed"));
538+
}
539+
540+
var claims = authenticateResult.Principal.Claims.ToList();
541+
var providerUserId = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
542+
var username = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Name)?.Value
543+
?? claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
544+
var email = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Email)?.Value;
545+
var displayName = claims.FirstOrDefault(c => c.Type == "name")?.Value;
546+
547+
if (string.IsNullOrWhiteSpace(providerUserId))
548+
{
549+
return Unauthorized(new ApiErrorResponse(
550+
ErrorCodes.AuthenticationFailed,
551+
$"OIDC provider '{provider.DisplayName}' did not return a user identifier"));
552+
}
553+
554+
if (string.IsNullOrWhiteSpace(email))
555+
email = $"{provider.Name.ToLowerInvariant()}-{providerUserId}@external.taskdeck.local";
556+
557+
if (string.IsNullOrWhiteSpace(username))
558+
username = $"{provider.Name.ToLowerInvariant()}-user-{providerUserId}";
559+
560+
var dto = new ExternalLoginDto(
561+
Provider: $"oidc_{provider.Name}",
562+
ProviderUserId: providerUserId,
563+
Username: username,
564+
Email: email,
565+
DisplayName: displayName,
566+
AvatarUrl: null);
567+
568+
var result = await _authService.ExternalLoginAsync(dto);
569+
570+
if (!result.IsSuccess)
571+
return result.ToErrorActionResult();
572+
573+
// Sign out the temporary cookie used during the OIDC handshake
574+
await HttpContext.SignOutAsync(AuthenticationRegistration.ExternalAuthenticationScheme);
575+
576+
// Store only the user ID in the auth code -- JWT is re-issued at exchange time.
577+
var code = GenerateAuthCode();
578+
var authCode = new OAuthAuthCode(
579+
code: code,
580+
userId: result.Value.User.Id,
581+
token: "placeholder", // Not stored; JWT re-issued at exchange
582+
expiresAt: DateTimeOffset.UtcNow.AddSeconds(60));
583+
584+
await _unitOfWork.OAuthAuthCodes.AddAsync(authCode);
585+
await _unitOfWork.SaveChangesAsync();
586+
587+
// Best-effort cleanup of expired/consumed codes
588+
await CleanupExpiredCodesAsync();
589+
590+
var safeReturnUrl = !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl)
591+
? returnUrl
592+
: "/";
593+
594+
var separator = safeReturnUrl.Contains('?') ? "&" : "?";
595+
return Redirect($"{safeReturnUrl}{separator}oauth_code={Uri.EscapeDataString(code)}&oauth_provider=oidc");
596+
}
597+
598+
/// <summary>
599+
/// Exchanges a short-lived OIDC authorization code for a JWT token.
600+
/// Reuses the same database-backed code store as GitHub OAuth.
601+
/// </summary>
602+
[HttpPost("oidc/exchange")]
603+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
604+
public async Task<IActionResult> OidcExchangeCode([FromBody] ExchangeCodeRequest request)
605+
{
606+
const string genericError = "Invalid or expired code";
607+
608+
if (string.IsNullOrWhiteSpace(request.Code))
609+
return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Code is required"));
610+
611+
var authCode = await _unitOfWork.OAuthAuthCodes.GetByCodeAsync(request.Code);
612+
if (authCode == null || authCode.IsLinkingCode || authCode.IsExpired || authCode.IsConsumed)
613+
return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, genericError));
614+
615+
var consumed = await _unitOfWork.OAuthAuthCodes.TryConsumeAtomicAsync(request.Code);
616+
if (!consumed)
617+
return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, genericError));
618+
619+
var user = await _unitOfWork.Users.GetByIdAsync(authCode.UserId);
620+
if (user == null)
621+
return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, genericError));
622+
623+
var userDto = new UserDto(
624+
user.Id,
625+
user.Username,
626+
user.Email,
627+
user.DefaultRole,
628+
user.IsActive,
629+
user.CreatedAt,
630+
user.UpdatedAt);
631+
632+
var freshToken = _authService.GenerateJwtToken(user);
633+
return Ok(new AuthResultDto(freshToken, userDto));
634+
}
635+
461636
private static string GenerateAuthCode()
462637
{
463638
var bytes = RandomNumberGenerator.GetBytes(32);
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.AspNetCore.RateLimiting;
4+
using Taskdeck.Api.Contracts;
5+
using Taskdeck.Api.Extensions;
6+
using Taskdeck.Api.RateLimiting;
7+
using Taskdeck.Application.DTOs;
8+
using Taskdeck.Application.Interfaces;
9+
using Taskdeck.Application.Services;
10+
using Taskdeck.Domain.Exceptions;
11+
12+
namespace Taskdeck.Api.Controllers;
13+
14+
/// <summary>
15+
/// MFA setup, verification, and status endpoints.
16+
/// All endpoints require authentication. MFA is optional and config-gated.
17+
/// </summary>
18+
[ApiController]
19+
[Route("api/auth/mfa")]
20+
[Authorize]
21+
[Produces("application/json")]
22+
public class MfaController : AuthenticatedControllerBase
23+
{
24+
private readonly MfaService _mfaService;
25+
26+
public MfaController(MfaService mfaService, IUserContext userContext)
27+
: base(userContext)
28+
{
29+
_mfaService = mfaService;
30+
}
31+
32+
/// <summary>
33+
/// Returns the current MFA status for the authenticated user.
34+
/// </summary>
35+
[HttpGet("status")]
36+
[ProducesResponseType(typeof(MfaStatusDto), StatusCodes.Status200OK)]
37+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
38+
public async Task<IActionResult> GetStatus()
39+
{
40+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
41+
return errorResult!;
42+
43+
var result = await _mfaService.GetStatusAsync(userId);
44+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
45+
}
46+
47+
/// <summary>
48+
/// Initiates MFA setup. Returns the shared secret, QR code URI, and recovery codes.
49+
/// The user must confirm setup by entering a valid TOTP code.
50+
/// </summary>
51+
[HttpPost("setup")]
52+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
53+
[ProducesResponseType(typeof(MfaSetupDto), StatusCodes.Status200OK)]
54+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
55+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
56+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
57+
public async Task<IActionResult> Setup()
58+
{
59+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
60+
return errorResult!;
61+
62+
var result = await _mfaService.SetupAsync(userId);
63+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
64+
}
65+
66+
/// <summary>
67+
/// Confirms MFA setup by validating a TOTP code from the user's authenticator app.
68+
/// </summary>
69+
[HttpPost("confirm")]
70+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
71+
[ProducesResponseType(StatusCodes.Status204NoContent)]
72+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
73+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
74+
public async Task<IActionResult> ConfirmSetup([FromBody] MfaVerifyRequest request)
75+
{
76+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
77+
return errorResult!;
78+
79+
var result = await _mfaService.ConfirmSetupAsync(userId, request.Code);
80+
return result.IsSuccess ? NoContent() : result.ToErrorActionResult();
81+
}
82+
83+
/// <summary>
84+
/// Verifies a TOTP code for a sensitive action gate.
85+
/// </summary>
86+
[HttpPost("verify")]
87+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
88+
[ProducesResponseType(StatusCodes.Status204NoContent)]
89+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
90+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
91+
public async Task<IActionResult> Verify([FromBody] MfaVerifyRequest request)
92+
{
93+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
94+
return errorResult!;
95+
96+
var result = await _mfaService.VerifyCodeAsync(userId, request.Code);
97+
return result.IsSuccess ? NoContent() : result.ToErrorActionResult();
98+
}
99+
100+
/// <summary>
101+
/// Disables MFA for the authenticated user. Requires a valid TOTP code.
102+
/// </summary>
103+
[HttpPost("disable")]
104+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
105+
[ProducesResponseType(StatusCodes.Status204NoContent)]
106+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
107+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
108+
public async Task<IActionResult> Disable([FromBody] MfaVerifyRequest request)
109+
{
110+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
111+
return errorResult!;
112+
113+
var result = await _mfaService.DisableAsync(userId, request.Code);
114+
return result.IsSuccess ? NoContent() : result.ToErrorActionResult();
115+
}
116+
}

backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
1919
services.AddScoped<LabelService>();
2020
services.AddScoped<AuthenticationService>();
2121
services.AddScoped<AuthorizationService>();
22+
services.AddScoped<MfaService>();
2223
services.AddScoped<IAuthorizationService>(sp => sp.GetRequiredService<AuthorizationService>());
2324
services.AddScoped<UserService>();
2425
services.AddScoped<BoardAccessService>();

0 commit comments

Comments
 (0)