2024-12-29 18:58:01 -06:00
using System.Net.Http.Headers ;
using System.Security.Claims ;
using System.Text.Json ;
using LANCommander.Server.Data.Models ;
2024-12-30 21:04:13 -06:00
using LANCommander.Server.Models ;
2024-12-29 18:58:01 -06:00
using LANCommander.Server.Services ;
2024-12-28 05:20:46 -06:00
using LANCommander.Server.Services.Models ;
using Microsoft.AspNetCore.Authentication ;
2024-12-29 18:58:01 -06:00
using Microsoft.AspNetCore.Identity ;
2024-12-28 05:20:46 -06:00
using Microsoft.IdentityModel.Protocols.OpenIdConnect ;
using Microsoft.IdentityModel.Tokens ;
2025-01-06 18:55:24 -06:00
using Serilog ;
2024-12-28 05:20:46 -06:00
2025-03-11 21:21:16 -05:00
namespace LANCommander.Server.Startup ;
2024-12-28 05:20:46 -06:00
2025-10-18 23:56:34 -05:00
public class AuthenticationFailure
{
public const string CallbackUrlMismatch = "Correlation failed." ;
}
2025-03-11 21:21:16 -05:00
public static class Authentication
2024-12-28 05:20:46 -06:00
{
2025-03-11 21:21:16 -05:00
public static WebApplicationBuilder ConfigureAuthentication ( this WebApplicationBuilder builder , Settings settings )
{
builder . Services . AddCascadingAuthenticationState ( ) ;
return builder ;
}
public static AuthenticationBuilder AddAuthenticationProviders ( this AuthenticationBuilder authBuilder , Settings settings )
{
foreach ( var authenticationProvider in settings . Authentication . AuthenticationProviders )
{
try
{
switch ( authenticationProvider . Type )
{
case AuthenticationProviderType . OAuth2 :
authBuilder . AddOAuth ( authenticationProvider ) ;
break ;
case AuthenticationProviderType . OpenIdConnect :
authBuilder . AddOpenIdConnect ( authenticationProvider ) ;
break ;
case AuthenticationProviderType . Saml :
throw new NotImplementedException ( "SAML providers are not supported at this time." ) ;
break ;
}
}
catch ( Exception ex )
{
Log . Error ( ex , "Authentication Provider {Name} could not be registered" ,
authenticationProvider . Name ) ;
}
}
return authBuilder ;
}
2024-12-28 05:20:46 -06:00
public static AuthenticationBuilder AddOpenIdConnect ( this AuthenticationBuilder authBuilder , AuthenticationProvider authenticationProvider )
{
2024-12-29 18:58:01 -06:00
if ( String . IsNullOrWhiteSpace ( authenticationProvider . Slug ) )
return authBuilder ;
2024-12-28 05:20:46 -06:00
2024-12-29 18:58:01 -06:00
return authBuilder . AddOpenIdConnect ( authenticationProvider . Slug , authenticationProvider . Name , options = >
2024-12-28 05:20:46 -06:00
{
options . ClientId = authenticationProvider . ClientId ;
options . ClientSecret = authenticationProvider . ClientSecret ;
options . Authority = authenticationProvider . Authority ;
options . ResponseType = OpenIdConnectResponseType . Code ;
options . TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false
} ;
options . Configuration = new OpenIdConnectConfiguration
{
AuthorizationEndpoint = authenticationProvider . AuthorizationEndpoint ,
TokenEndpoint = authenticationProvider . TokenEndpoint ,
UserInfoEndpoint = authenticationProvider . UserInfoEndpoint ,
} ;
// Callbacks for middleware to properly correlate
2024-12-29 18:58:01 -06:00
options . CallbackPath = new PathString ( $"/SignInOIDC" ) ;
options . SignedOutCallbackPath = new PathString ( $"/SignOutOIDC" ) ;
2024-12-28 05:20:46 -06:00
foreach ( var scope in authenticationProvider . Scopes )
{
options . Scope . Add ( scope ) ;
}
foreach ( var claimMapping in authenticationProvider . ClaimMappings )
{
if ( ! String . IsNullOrWhiteSpace ( claimMapping . Name ) & & ! String . IsNullOrWhiteSpace ( claimMapping . Value ) )
options . ClaimActions . MapJsonKey ( claimMapping . Name , claimMapping . Value ) ;
}
2025-01-06 18:55:24 -06:00
options . Events . OnRemoteFailure = async context = >
{
context . Response . Redirect ( "/Login" ) ;
2025-10-18 23:56:34 -05:00
switch ( context . Failure ? . Message )
{
case AuthenticationFailure . CallbackUrlMismatch :
Log . Error ( context . Failure , "The identity provider is not configured for the callback URL {CallbackUrl}" , $"{context.Request.Scheme}://{context.Request.Host}{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}" ) ;
break ;
default :
Log . Error ( context . Failure , "OIDC authentication failed" ) ;
break ;
}
2025-01-06 18:55:24 -06:00
await context . Response . CompleteAsync ( ) ;
} ;
options . Events . OnTokenValidated = async context = >
{
var identity = new ClaimsIdentity ( context . Principal . Claims , IdentityConstants . ApplicationScheme ) ;
await ProcessLogin ( context . HttpContext , context . Response , identity , authenticationProvider , context . Properties ) ;
await context . Response . CompleteAsync ( ) ;
} ;
2024-12-28 05:20:46 -06:00
} ) ;
}
public static AuthenticationBuilder AddOAuth ( this AuthenticationBuilder authBuilder ,
AuthenticationProvider authenticationProvider )
{
2024-12-29 18:58:01 -06:00
if ( String . IsNullOrWhiteSpace ( authenticationProvider . Slug ) )
return authBuilder ;
2024-12-28 05:20:46 -06:00
2024-12-29 18:58:01 -06:00
return authBuilder . AddOAuth ( authenticationProvider . Slug , authenticationProvider . Name , options = >
2024-12-28 05:20:46 -06:00
{
options . ClientId = authenticationProvider . ClientId ;
options . ClientSecret = authenticationProvider . ClientSecret ;
2024-12-29 18:58:01 -06:00
options . CallbackPath = new PathString ( $"/SignInOAuth" ) ;
2024-12-28 05:20:46 -06:00
options . AuthorizationEndpoint = authenticationProvider . AuthorizationEndpoint ;
options . TokenEndpoint = authenticationProvider . TokenEndpoint ;
options . UserInformationEndpoint = authenticationProvider . UserInfoEndpoint ;
foreach ( var scope in authenticationProvider . Scopes )
{
options . Scope . Add ( scope ) ;
}
options . SaveTokens = true ;
foreach ( var claimMapping in authenticationProvider . ClaimMappings )
{
if ( ! String . IsNullOrWhiteSpace ( claimMapping . Name ) & & ! String . IsNullOrWhiteSpace ( claimMapping . Value ) )
options . ClaimActions . MapJsonKey ( claimMapping . Name , claimMapping . Value ) ;
}
2025-01-06 18:55:24 -06:00
options . Events . OnRemoteFailure = async context = >
{
context . Response . Redirect ( "/Login" ) ;
Log . Error ( context . Failure , "OAuth authentication failed" ) ;
await context . Response . CompleteAsync ( ) ;
} ;
2024-12-29 18:58:01 -06:00
options . Events . OnCreatingTicket = async context = >
2024-12-28 05:20:46 -06:00
{
var request = new HttpRequestMessage ( HttpMethod . Get , context . Options . UserInformationEndpoint ) ;
2025-01-06 18:55:24 -06:00
2024-12-28 05:20:46 -06:00
request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , context . AccessToken ) ;
var response = await context . Backchannel . SendAsync ( request ) ;
2025-01-06 18:55:24 -06:00
2024-12-28 05:20:46 -06:00
if ( ! response . IsSuccessStatusCode )
2025-01-06 18:55:24 -06:00
throw new HttpRequestException (
$"An error occurred while retrieving the user profile: {response.StatusCode}" ) ;
2024-12-28 05:20:46 -06:00
2024-12-29 18:58:01 -06:00
var oauthUser = JsonDocument . Parse ( await response . Content . ReadAsStringAsync ( ) ) ;
2025-01-06 18:55:24 -06:00
2024-12-29 18:58:01 -06:00
context . Identity . AddClaim ( new Claim ( "Provider" , authenticationProvider . Name ) ) ;
2025-01-06 18:55:24 -06:00
2024-12-29 18:58:01 -06:00
context . RunClaimActions ( oauthUser . RootElement ) ;
2025-01-06 18:55:24 -06:00
var identity = new ClaimsIdentity ( context . Identity . Claims , IdentityConstants . ApplicationScheme ) ;
2024-12-29 18:58:01 -06:00
2025-01-06 18:55:24 -06:00
await ProcessLogin ( context . HttpContext , context . Response , identity , authenticationProvider , context . Properties ) ;
2025-01-06 02:07:25 -06:00
2025-01-06 18:55:24 -06:00
await context . Response . CompleteAsync ( ) ;
} ;
} ) ;
}
2024-12-30 14:16:49 -06:00
2025-01-06 18:55:24 -06:00
private static async Task ProcessLogin (
HttpContext httpContext ,
HttpResponse response ,
ClaimsIdentity identity ,
AuthenticationProvider authenticationProvider ,
AuthenticationProperties properties )
{
var signInManager = httpContext . RequestServices . GetService < SignInManager < User > > ( ) ! ;
var userService = httpContext . RequestServices . GetService < UserService > ( ) ! ;
var userCustomFieldService = httpContext . RequestServices . GetService < UserCustomFieldService > ( ) ! ;
var idClaim = identity . FindFirst ( ClaimTypes . NameIdentifier ) ;
2024-12-30 21:04:13 -06:00
2025-01-06 18:55:24 -06:00
User user ;
UserCustomField customField ;
var principal = new ClaimsPrincipal ( identity ) ;
var action = properties . Items [ "Action" ] ;
2024-12-30 21:11:48 -06:00
2025-01-06 18:55:24 -06:00
switch ( action )
{
case AuthenticationProviderActionType . Login :
customField = await userCustomFieldService . FirstOrDefaultAsync ( cf = > cf . Name = = authenticationProvider . GetCustomFieldName ( ) & & cf . Value = = idClaim . Value ) ;
2024-12-30 21:11:48 -06:00
2025-01-06 18:55:24 -06:00
if ( customField ! = null )
{
user = await userService . GetAsync ( customField . UserId . Value ) ;
await signInManager . SignInAsync ( user , true ) ;
2024-12-30 14:16:49 -06:00
2025-01-06 18:55:24 -06:00
response . Redirect ( properties . RedirectUri ) ;
}
else
{
await httpContext . SignInAsync ( IdentityConstants . ApplicationScheme ,
principal ,
new AuthenticationProperties
2024-12-30 14:16:49 -06:00
{
2025-01-06 18:55:24 -06:00
AllowRefresh = false ,
IsPersistent = false ,
} ) ;
2024-12-30 14:16:49 -06:00
2025-01-06 18:55:24 -06:00
response . Redirect ( $"/Register?Provider={authenticationProvider.Slug}" ) ;
2024-12-30 14:16:49 -06:00
}
2025-01-06 18:55:24 -06:00
break ;
case AuthenticationProviderActionType . AccountLink :
// Link accounts if needed
var userId = Guid . Parse ( properties . Items [ "UserId" ] ) ;
user = await userService . GetAsync ( userId ) ;
customField = await userCustomFieldService . FirstOrDefaultAsync ( cf = > cf . UserId = = userId & & cf . Name = = authenticationProvider . GetCustomFieldName ( ) ) ;
2024-12-30 14:16:49 -06:00
2025-01-06 18:55:24 -06:00
var collidingCustomField = await userCustomFieldService . FirstOrDefaultAsync ( cf = > cf . Name = = authenticationProvider . GetCustomFieldName ( ) & & cf . Value = = idClaim . Value ) ;
if ( collidingCustomField ! = null )
throw new Exception ( "This account is already linked to an existing user." ) ;
if ( customField = = null )
{
await userCustomFieldService . AddAsync ( new UserCustomField
{
Name = authenticationProvider . GetCustomFieldName ( ) ,
UserId = userId ,
Value = idClaim . Value
} ) ;
}
await signInManager . SignInAsync ( user , true ) ;
response . Redirect ( properties . RedirectUri ) ;
break ;
case AuthenticationProviderActionType . Register :
await httpContext . SignInAsync ( IdentityConstants . ApplicationScheme ,
principal ,
new AuthenticationProperties
{
AllowRefresh = false ,
IsPersistent = false ,
} ) ;
response . Redirect ( $"/Register?Provider={authenticationProvider.Slug}" ) ;
break ;
}
2024-12-28 05:20:46 -06:00
}
}