2024-06-04 17:38:59 +02:00
using System.Security.Cryptography ;
2024-11-03 02:07:07 +01:00
using Foxnouns.Backend.Controllers.Authentication ;
2024-06-04 17:38:59 +02:00
using Foxnouns.Backend.Database ;
using Foxnouns.Backend.Database.Models ;
using Foxnouns.Backend.Utils ;
using Microsoft.AspNetCore.Identity ;
2024-06-12 03:47:20 +02:00
using Microsoft.EntityFrameworkCore ;
2024-06-04 17:38:59 +02:00
using NodaTime ;
2024-11-03 02:07:07 +01:00
namespace Foxnouns.Backend.Services.Auth ;
2024-06-04 17:38:59 +02:00
2024-11-03 02:07:07 +01:00
public class AuthService (
IClock clock ,
ILogger logger ,
DatabaseContext db ,
ISnowflakeGenerator snowflakeGenerator ,
UserRendererService userRenderer
)
2024-06-04 17:38:59 +02:00
{
2024-11-03 02:07:07 +01:00
private readonly ILogger _logger = logger . ForContext < AuthService > ( ) ;
2024-06-04 17:38:59 +02:00
private readonly PasswordHasher < User > _passwordHasher = new ( ) ;
/// <summary>
/// Creates a new user with the given email address and password.
/// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />.
/// </summary>
2024-10-02 00:28:07 +02:00
public async Task < User > CreateUserWithPasswordAsync (
string username ,
string email ,
string password ,
CancellationToken ct = default
)
2024-06-04 17:38:59 +02:00
{
var user = new User
{
Id = snowflakeGenerator . GenerateSnowflake ( ) ,
Username = username ,
2024-06-12 16:19:49 +02:00
AuthMethods =
{
new AuthMethod
2024-10-02 00:28:07 +02:00
{
Id = snowflakeGenerator . GenerateSnowflake ( ) ,
AuthType = AuthType . Email ,
RemoteId = email ,
} ,
2024-07-13 03:09:00 +02:00
} ,
2024-10-02 00:28:07 +02:00
LastActive = clock . GetCurrentInstant ( ) ,
2024-11-24 15:39:44 +01:00
Sid = null ! ,
2024-06-04 17:38:59 +02:00
} ;
db . Add ( user ) ;
2024-09-10 02:39:07 +02:00
user . Password = await Task . Run ( ( ) = > _passwordHasher . HashPassword ( user , password ) , ct ) ;
2024-06-04 17:38:59 +02:00
return user ;
}
2024-07-08 19:03:04 +02:00
2024-06-13 02:23:55 +02:00
/// <summary>
/// Creates a new user with the given username and remote authentication method.
/// To create a user with email authentication, use <see cref="CreateUserWithPasswordAsync" />
/// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />.
/// </summary>
2024-10-02 00:28:07 +02:00
public async Task < User > CreateUserWithRemoteAuthAsync (
string username ,
AuthType authType ,
string remoteId ,
string remoteUsername ,
FediverseApplication ? instance = null ,
CancellationToken ct = default
)
2024-06-13 02:23:55 +02:00
{
AssertValidAuthType ( authType , instance ) ;
2024-07-08 19:03:04 +02:00
2024-09-09 14:37:59 +02:00
if ( await db . Users . AnyAsync ( u = > u . Username = = username , ct ) )
2024-07-14 16:44:41 +02:00
throw new ApiError . BadRequest ( "Username is already taken" , "username" , username ) ;
2024-06-13 02:23:55 +02:00
var user = new User
{
Id = snowflakeGenerator . GenerateSnowflake ( ) ,
Username = username ,
AuthMethods =
{
new AuthMethod
{
2024-10-02 00:28:07 +02:00
Id = snowflakeGenerator . GenerateSnowflake ( ) ,
AuthType = authType ,
RemoteId = remoteId ,
RemoteUsername = remoteUsername ,
FediverseApplication = instance ,
} ,
2024-07-13 03:09:00 +02:00
} ,
2024-10-02 00:28:07 +02:00
LastActive = clock . GetCurrentInstant ( ) ,
2024-11-24 15:39:44 +01:00
Sid = null ! ,
2024-06-13 02:23:55 +02:00
} ;
db . Add ( user ) ;
return user ;
}
2024-06-04 17:38:59 +02:00
2024-06-12 16:19:49 +02:00
/// <summary>
/// Authenticates a user with email and password.
/// </summary>
/// <param name="email">The user's email address</param>
/// <param name="password">The user's password, in plain text</param>
2024-09-14 16:37:52 +02:00
/// <param name="ct">Cancellation token</param>
2024-06-12 16:19:49 +02:00
/// <returns>A tuple of the authenticated user and whether multi-factor authentication is required</returns>
/// <exception cref="ApiError.NotFound">Thrown if the email address is not associated with any user
/// or if the password is incorrect</exception>
2024-10-02 00:28:07 +02:00
public async Task < ( User , EmailAuthenticationResult ) > AuthenticateUserAsync (
string email ,
string password ,
CancellationToken ct = default
)
2024-06-12 03:47:20 +02:00
{
2024-10-02 00:28:07 +02:00
var user = await db . Users . FirstOrDefaultAsync (
u = > u . AuthMethods . Any ( a = > a . AuthType = = AuthType . Email & & a . RemoteId = = email ) ,
ct
) ;
2024-06-12 16:19:49 +02:00
if ( user = = null )
2024-10-02 00:28:07 +02:00
throw new ApiError . NotFound (
"No user with that email address found, or password is incorrect" ,
ErrorCode . UserNotFound
) ;
var pwResult = await Task . Run (
( ) = > _passwordHasher . VerifyHashedPassword ( user , user . Password ! , password ) ,
ct
) ;
2024-09-10 21:24:40 +02:00
if ( pwResult = = PasswordVerificationResult . Failed ) // TODO: this seems to fail on some valid passwords?
2024-10-02 00:28:07 +02:00
throw new ApiError . NotFound (
"No user with that email address found, or password is incorrect" ,
ErrorCode . UserNotFound
) ;
2024-06-12 03:47:20 +02:00
if ( pwResult = = PasswordVerificationResult . SuccessRehashNeeded )
{
2024-09-09 14:37:59 +02:00
user . Password = await Task . Run ( ( ) = > _passwordHasher . HashPassword ( user , password ) , ct ) ;
await db . SaveChangesAsync ( ct ) ;
2024-06-12 03:47:20 +02:00
}
2024-06-12 16:19:49 +02:00
return ( user , EmailAuthenticationResult . AuthSuccessful ) ;
}
2024-10-02 02:46:39 +02:00
public enum EmailAuthenticationResult
{
AuthSuccessful ,
MfaRequired ,
}
2024-10-02 00:52:49 +02:00
/// <summary>
/// Validates a user's password outside an authentication context, for when a password is required for changing
/// a setting, such as adding a new email address or changing passwords.
/// </summary>
public async Task < bool > ValidatePasswordAsync (
User user ,
string password ,
CancellationToken ct = default
)
{
if ( user . Password = = null )
{
throw new FoxnounsError ( "Password for user supplied to ValidatePasswordAsync was null" ) ;
}
var pwResult = await Task . Run (
( ) = > _passwordHasher . VerifyHashedPassword ( user , user . Password ! , password ) ,
ct
) ;
return pwResult
is PasswordVerificationResult . SuccessRehashNeeded
or PasswordVerificationResult . Success ;
}
2024-10-02 02:46:39 +02:00
/// <summary>
/// Sets or updates a password for the given user. This method does <i>not</i> save the updated password automatically.
/// </summary>
public async Task SetUserPasswordAsync (
User user ,
string password ,
CancellationToken ct = default
)
2024-06-12 16:19:49 +02:00
{
2024-10-02 02:46:39 +02:00
user . Password = await Task . Run ( ( ) = > _passwordHasher . HashPassword ( user , password ) , ct ) ;
db . Update ( user ) ;
2024-06-12 16:19:49 +02:00
}
/// <summary>
/// Authenticates a user with a remote authentication provider.
/// </summary>
/// <param name="authType">The remote authentication provider type</param>
/// <param name="remoteId">The remote user ID</param>
/// <param name="instance">The Fediverse instance, if authType is Fediverse.
/// Will throw an exception if passed with another authType.</param>
2024-09-09 14:37:59 +02:00
/// <param name="ct">Cancellation token.</param>
2024-06-12 16:19:49 +02:00
/// <returns>A user object, or null if the remote account isn't linked to any user.</returns>
/// <exception cref="FoxnounsError">Thrown if <c>instance</c> is passed when not required,
/// or not passed when required</exception>
2024-10-02 00:28:07 +02:00
public async Task < User ? > AuthenticateUserAsync (
AuthType authType ,
string remoteId ,
FediverseApplication ? instance = null ,
CancellationToken ct = default
)
2024-06-12 16:19:49 +02:00
{
2024-06-13 02:23:55 +02:00
AssertValidAuthType ( authType , instance ) ;
2024-06-12 16:19:49 +02:00
2024-10-02 00:28:07 +02:00
return await db . Users . FirstOrDefaultAsync (
u = >
u . AuthMethods . Any ( a = >
a . AuthType = = authType
& & a . RemoteId = = remoteId
& & a . FediverseApplication = = instance
) ,
ct
) ;
2024-09-09 14:37:59 +02:00
}
2024-10-02 00:28:07 +02:00
public async Task < AuthMethod > AddAuthMethodAsync (
Snowflake userId ,
AuthType authType ,
string remoteId ,
2024-09-09 14:37:59 +02:00
string? remoteUsername = null ,
2024-12-04 01:48:52 +01:00
FediverseApplication ? app = null ,
2024-10-02 00:28:07 +02:00
CancellationToken ct = default
)
2024-09-09 14:37:59 +02:00
{
2024-12-04 01:48:52 +01:00
AssertValidAuthType ( authType , app ) ;
2024-09-09 14:37:59 +02:00
2024-11-28 21:35:55 +01:00
// This is already checked when
var currentCount = await db
. AuthMethods . Where ( m = > m . UserId = = userId & & m . AuthType = = authType )
. CountAsync ( ct ) ;
if ( currentCount > = AuthUtils . MaxAuthMethodsPerType )
throw new ApiError . BadRequest (
"Too many linked accounts of this type, maximum of 3 per account."
) ;
2024-09-09 14:37:59 +02:00
var authMethod = new AuthMethod
{
Id = snowflakeGenerator . GenerateSnowflake ( ) ,
AuthType = authType ,
RemoteId = remoteId ,
2024-12-04 01:48:52 +01:00
FediverseApplicationId = app ? . Id ,
2024-09-09 14:37:59 +02:00
RemoteUsername = remoteUsername ,
2024-10-02 00:28:07 +02:00
UserId = userId ,
2024-09-09 14:37:59 +02:00
} ;
db . Add ( authMethod ) ;
await db . SaveChangesAsync ( ct ) ;
return authMethod ;
2024-06-12 03:47:20 +02:00
}
2024-10-02 00:28:07 +02:00
public ( string , Token ) GenerateToken (
User user ,
Application application ,
string [ ] scopes ,
Instant expires
)
2024-06-04 17:38:59 +02:00
{
2024-07-08 19:03:04 +02:00
if ( ! AuthUtils . ValidateScopes ( application , scopes ) )
2024-10-02 00:28:07 +02:00
throw new ApiError . BadRequest (
"Invalid scopes requested for this token" ,
"scopes" ,
scopes
) ;
2024-06-04 17:38:59 +02:00
var ( token , hash ) = GenerateToken ( ) ;
2024-10-02 00:28:07 +02:00
return (
token ,
new Token
{
Id = snowflakeGenerator . GenerateSnowflake ( ) ,
Hash = hash ,
Application = application ,
User = user ,
ExpiresAt = expires ,
Scopes = scopes ,
}
) ;
2024-06-04 17:38:59 +02:00
}
2024-11-03 02:07:07 +01:00
/// <summary>
/// Generates a token for the given user and adds it to the database, returning a fully formed auth response for the user.
/// This method is always called at the end of an endpoint method, so the resulting token
/// (and user, if this is a registration request) is also saved to the database.
/// </summary>
public async Task < CallbackResponse > GenerateUserTokenAsync (
User user ,
CancellationToken ct = default
)
{
var frontendApp = await db . GetFrontendApplicationAsync ( ct ) ;
var ( tokenStr , token ) = GenerateToken (
user ,
frontendApp ,
["*"] ,
clock . GetCurrentInstant ( ) + Duration . FromDays ( 365 )
) ;
db . Add ( token ) ;
_logger . Debug ( "Generated token {TokenId} for {UserId}" , user . Id , token . Id ) ;
await db . SaveChangesAsync ( ct ) ;
return new CallbackResponse (
HasAccount : true ,
Ticket : null ,
RemoteUsername : null ,
User : await userRenderer . RenderUserAsync (
user ,
selfUser : user ,
renderMembers : false ,
ct : ct
) ,
Token : tokenStr ,
ExpiresAt : token . ExpiresAt
) ;
}
2024-06-04 17:38:59 +02:00
private static ( string , byte [ ] ) GenerateToken ( )
{
2024-07-13 19:38:40 +02:00
var token = AuthUtils . RandomToken ( ) ;
2024-06-04 17:38:59 +02:00
var hash = SHA512 . HashData ( Convert . FromBase64String ( token ) ) ;
return ( token , hash ) ;
}
2024-06-13 02:23:55 +02:00
private static void AssertValidAuthType ( AuthType authType , FediverseApplication ? instance )
{
if ( authType = = AuthType . Fediverse & & instance = = null )
throw new FoxnounsError ( "Fediverse authentication requires an instance." ) ;
if ( authType ! = AuthType . Fediverse & & instance ! = null )
throw new FoxnounsError ( "Non-Fediverse authentication does not require an instance." ) ;
}
2024-10-02 00:28:07 +02:00
}