2024-12-09 21:11:46 +01:00
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
2024-06-04 17:38:59 +02:00
using System.Security.Cryptography ;
using Foxnouns.Backend.Database ;
using Foxnouns.Backend.Database.Models ;
2024-12-08 20:17:30 +01:00
using Foxnouns.Backend.Dto ;
2024-06-04 17:38:59 +02:00
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-12-25 17:19:50 +01:00
using XidNet ;
2024-06-04 17:38:59 +02:00
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
{
2024-12-04 17:43:02 +01:00
// Validate username and whether it's not taken
ValidationUtils . Validate (
[
( "username" , ValidationUtils . ValidateUsername ( username ) ) ,
( "password" , ValidationUtils . ValidatePassword ( password ) ) ,
]
) ;
if ( await db . Users . AnyAsync ( u = > u . Username = = username , ct ) )
throw new ApiError . BadRequest ( "Username is already taken" , "username" , username ) ;
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-12-25 17:19:50 +01:00
LegacyId = Xid . NewXid ( ) . ToString ( ) ,
2024-06-04 17:38:59 +02:00
} ;
db . Add ( user ) ;
2024-12-04 17:43:02 +01:00
user . Password = await HashPasswordAsync ( 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-12-04 17:43:02 +01:00
// Validate username and whether it's not taken
ValidationUtils . Validate ( [ ( "username" , ValidationUtils . ValidateUsername ( username ) ) ] ) ;
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-12-25 17:19:50 +01:00
LegacyId = Xid . NewXid ( ) . ToString ( ) ,
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-12-08 15:07:25 +01:00
User ? user = await db . Users . FirstOrDefaultAsync (
2024-10-02 00:28:07 +02:00
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-12-08 15:07:25 +01:00
{
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-12-08 15:07:25 +01:00
}
2024-10-02 00:28:07 +02:00
2024-12-08 15:07:25 +01:00
PasswordVerificationResult pwResult = await VerifyHashedPasswordAsync ( user , password , ct ) ;
2024-09-10 21:24:40 +02:00
if ( pwResult = = PasswordVerificationResult . Failed ) // TODO: this seems to fail on some valid passwords?
2024-12-08 15:07:25 +01:00
{
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-12-08 15:07:25 +01:00
}
2024-06-12 03:47:20 +02:00
if ( pwResult = = PasswordVerificationResult . SuccessRehashNeeded )
{
2024-12-04 17:43:02 +01:00
user . Password = await HashPasswordAsync ( user , password , ct ) ;
2024-09-09 14:37:59 +02:00
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" ) ;
}
2024-12-08 15:07:25 +01:00
PasswordVerificationResult pwResult = await VerifyHashedPasswordAsync ( user , password , ct ) ;
2024-10-02 00:52:49 +02:00
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-12-04 17:43:02 +01:00
user . Password = await HashPasswordAsync ( user , password , ct ) ;
2024-10-02 02:46:39 +02:00
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
2024-12-08 15:07:25 +01:00
int currentCount = await db
2024-11-28 21:35:55 +01:00
. AuthMethods . Where ( m = > m . UserId = = userId & & m . AuthType = = authType )
. CountAsync ( ct ) ;
if ( currentCount > = AuthUtils . MaxAuthMethodsPerType )
2024-12-08 15:07:25 +01:00
{
2024-11-28 21:35:55 +01:00
throw new ApiError . BadRequest (
"Too many linked accounts of this type, maximum of 3 per account."
) ;
2024-12-08 15:07:25 +01:00
}
2024-11-28 21:35:55 +01:00
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-12-08 15:07:25 +01:00
{
2024-10-02 00:28:07 +02:00
throw new ApiError . BadRequest (
"Invalid scopes requested for this token" ,
"scopes" ,
scopes
) ;
2024-12-08 15:07:25 +01:00
}
2024-06-04 17:38:59 +02:00
2024-12-08 15:07:25 +01:00
( string? token , byte [ ] ? 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
)
{
2024-12-08 15:07:25 +01:00
Application frontendApp = await db . GetFrontendApplicationAsync ( ct ) ;
2024-11-03 02:07:07 +01:00
2024-12-08 15:07:25 +01:00
( string? tokenStr , Token ? token ) = GenerateToken (
2024-11-03 02:07:07 +01:00
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 (
2024-12-08 15:07:25 +01:00
true ,
null ,
null ,
await userRenderer . RenderUserAsync ( user , user , renderMembers : false , ct : ct ) ,
tokenStr ,
token . ExpiresAt
2024-11-03 02:07:07 +01:00
) ;
}
2024-12-04 17:43:02 +01:00
private Task < string > HashPasswordAsync (
User user ,
string password ,
CancellationToken ct = default
) = > Task . Run ( ( ) = > _passwordHasher . HashPassword ( user , password ) , ct ) ;
private Task < PasswordVerificationResult > VerifyHashedPasswordAsync (
User user ,
string providedPassword ,
CancellationToken ct = default
) = >
Task . Run (
( ) = > _passwordHasher . VerifyHashedPassword ( user , user . Password ! , providedPassword ) ,
ct
) ;
2024-06-04 17:38:59 +02:00
private static ( string , byte [ ] ) GenerateToken ( )
{
2024-12-14 16:39:02 +01:00
string token = AuthUtils . RandomUrlUnsafeToken ( ) ;
2024-12-08 15:07:25 +01:00
byte [ ] hash = SHA512 . HashData ( Convert . FromBase64String ( token ) ) ;
2024-06-04 17:38:59 +02:00
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
}