2024-06-04 17:38:59 +02:00
using System.Security.Cryptography ;
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 ;
namespace Foxnouns.Backend.Services ;
2024-07-13 19:38:40 +02:00
public class AuthService ( IClock clock , DatabaseContext db , ISnowflakeGenerator snowflakeGenerator )
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-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-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 ) ;
}
public enum EmailAuthenticationResult
{
AuthSuccessful ,
MfaRequired ,
}
/// <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-10-02 00:28:07 +02:00
CancellationToken ct = default
)
2024-09-09 14:37:59 +02:00
{
AssertValidAuthType ( authType , null ) ;
var authMethod = new AuthMethod
{
Id = snowflakeGenerator . GenerateSnowflake ( ) ,
AuthType = authType ,
RemoteId = remoteId ,
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
}
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
}