diff --git a/Alveus.ruleset b/Alveus.ruleset
index eefa5a6..240d57b 100644
--- a/Alveus.ruleset
+++ b/Alveus.ruleset
@@ -16,7 +16,7 @@
-
+
diff --git a/Astral.ApiServer/Constants/ApiErrorCodes.cs b/Astral.ApiServer/Constants/ApiErrorCodes.cs
new file mode 100644
index 0000000..c32cedd
--- /dev/null
+++ b/Astral.ApiServer/Constants/ApiErrorCodes.cs
@@ -0,0 +1,21 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+namespace Astral.ApiServer.Constants;
+
+///
+/// Collection of API error codes.
+///
+public static class ApiErrorCodes
+{
+ ///
+ /// Attempted to authenticate with an unsupported grant type.
+ ///
+ public const string UnsupportedGrantType = "unsupported-grant-type";
+
+ ///
+ /// Attempted to authenticate with an unsupported token scope.
+ ///
+ public const string UnsupportedTokenScope = "unsupported-token-scope";
+}
diff --git a/Astral.ApiServer/Constants/OAuthGrantTypes.cs b/Astral.ApiServer/Constants/OAuthGrantType.cs
similarity index 66%
rename from Astral.ApiServer/Constants/OAuthGrantTypes.cs
rename to Astral.ApiServer/Constants/OAuthGrantType.cs
index 2e7c827..191deca 100644
--- a/Astral.ApiServer/Constants/OAuthGrantTypes.cs
+++ b/Astral.ApiServer/Constants/OAuthGrantType.cs
@@ -1,4 +1,4 @@
-//
+//
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
//
@@ -7,10 +7,10 @@ namespace Astral.ApiServer.Constants;
///
/// Available grant types for auth token requests.
///
-public static class OAuthGrantTypes
+public enum OAuthGrantType
{
///
/// Password grant type.
///
- public const string Password = "password";
+ Password
}
diff --git a/Astral.ApiServer/Controllers/BaseApiController.cs b/Astral.ApiServer/Controllers/BaseApiController.cs
new file mode 100644
index 0000000..6d9325b
--- /dev/null
+++ b/Astral.ApiServer/Controllers/BaseApiController.cs
@@ -0,0 +1,98 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using System.Net;
+using System.Net.Sockets;
+using Astral.ApiServer.Models.Common;
+using Astral.Core.Constants;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Astral.ApiServer.Controllers;
+
+///
+/// Base API controller with common methods.
+///
+[Produces("application/json")]
+[Consumes("application/x-www-form-urlencoded")]
+public class BaseApiController : ControllerBase
+{
+ ///
+ /// Return a failure status with no data.
+ ///
+ /// Instance of .
+ protected IActionResult FailureResult()
+ {
+ Response.StatusCode = (int)HttpStatusCode.BadRequest;
+ return new JsonResult(new ResultModel
+ {
+ Success = false,
+ });
+ }
+
+ ///
+ /// Return a failure result with additional information.
+ ///
+ /// Api error code.
+ /// Api error message.
+ /// Instance of .
+ protected IActionResult FailureResult(string errorCode, string message)
+ {
+ Response.StatusCode = (int)HttpStatusCode.BadRequest;
+ return new JsonResult(new ErrorResultModel
+ {
+ Error = errorCode,
+ Message = message,
+ });
+ }
+
+ ///
+ /// Returns a failure result indicating the body is missing from the request.
+ ///
+ /// Instance of .
+ protected IActionResult MissingBodyResult()
+ {
+ return FailureResult(CoreErrorCodes.NoDataProvided, "Missing request body");
+ }
+
+ ///
+ /// Return a success status with no data.
+ ///
+ /// Instance of .
+ protected IActionResult SuccessResult()
+ {
+ Response.StatusCode = (int)HttpStatusCode.OK;
+ return new JsonResult(new ResultModel
+ {
+ Success = true,
+ });
+ }
+
+ ///
+ /// Fetch IP address of requesting agent.
+ ///
+ /// Request origin's IP address.
+ protected IPAddress ClientIpAddress()
+ {
+ IPAddress remoteIpAddress = null;
+ if (Request.Headers.TryGetValue("X-Forwarded-For", out var value))
+ {
+ foreach (var ip in value)
+ {
+ if (IPAddress.TryParse(ip, out var address) &&
+ (address.AddressFamily is AddressFamily.InterNetwork
+ or AddressFamily.InterNetworkV6))
+ {
+ remoteIpAddress = address;
+ break;
+ }
+ }
+ }
+ else
+ {
+ remoteIpAddress = HttpContext.Connection.RemoteIpAddress?.MapToIPv4();
+ }
+
+ return remoteIpAddress;
+ }
+}
diff --git a/Astral.ApiServer/Controllers/OAuthController.cs b/Astral.ApiServer/Controllers/OAuthController.cs
index 8c701e3..0b9b94f 100644
--- a/Astral.ApiServer/Controllers/OAuthController.cs
+++ b/Astral.ApiServer/Controllers/OAuthController.cs
@@ -2,7 +2,11 @@
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
//
+using Astral.ApiServer.Constants;
using Astral.ApiServer.Models;
+using Astral.Core.Constants;
+using Astral.Services.Dtos;
+using Astral.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -11,19 +15,59 @@ namespace Astral.ApiServer.Controllers;
///
/// OAuth authentication controller.
///
-[Produces("application/json")]
-[Consumes("application/x-www-form-urlencoded")]
[Route("oauth")]
-public class OAuthController : ControllerBase
+public class OAuthController : BaseApiController
{
+ private readonly IAuthenticationService _authenticationService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of .
+ public OAuthController(IAuthenticationService authenticationService)
+ {
+ _authenticationService = authenticationService;
+ }
+
///
/// Grant token request.
///
/// Instance of .
[HttpPost("token")]
[AllowAnonymous]
- public Task GrantToken([FromForm] TokenGrantRequestModel tokenGrantRequest)
+ public async Task GrantToken([FromForm] TokenGrantRequestModel tokenGrantRequest)
{
- throw new NotImplementedException();
+ if (tokenGrantRequest is null)
+ {
+ return MissingBodyResult();
+ }
+
+ if (!Enum.TryParse(tokenGrantRequest.GrantType, true, out OAuthGrantType grantType))
+ {
+ return FailureResult(ApiErrorCodes.UnsupportedGrantType, "Unknown grant type");
+ }
+
+ if (!Enum.TryParse(tokenGrantRequest.Scope, true, out TokenScope tokenScope))
+ {
+ return FailureResult(ApiErrorCodes.UnsupportedTokenScope, "Unknown token scope");
+ }
+
+ switch (grantType)
+ {
+ case OAuthGrantType.Password:
+ var request = new PasswordAuthenticateDto
+ {
+ Username = tokenGrantRequest.Username,
+ Password = tokenGrantRequest.Password,
+ Scope = tokenScope,
+ IpAddress = ClientIpAddress()
+ };
+
+ var result = await _authenticationService.AuthenticateSession(request);
+
+ return new JsonResult(new TokenGrantResultModel(result));
+ }
+
+ return FailureResult();
}
}
diff --git a/Astral.ApiServer/Models/Common/ErrorResultModel.cs b/Astral.ApiServer/Models/Common/ErrorResultModel.cs
index 66907b4..23f3a30 100644
--- a/Astral.ApiServer/Models/Common/ErrorResultModel.cs
+++ b/Astral.ApiServer/Models/Common/ErrorResultModel.cs
@@ -9,14 +9,8 @@ namespace Astral.ApiServer.Models.Common;
///
/// Error result model.
///
-public class ErrorResultModel
+public class ErrorResultModel : ResultModel
{
- ///
- /// Status: failure.
- ///
- [JsonPropertyName("status")]
- public const string Status = "failure";
-
///
/// Error code.
///
diff --git a/Astral.ApiServer/Models/Common/ResultModel.cs b/Astral.ApiServer/Models/Common/ResultModel.cs
index 8f30ee4..6f0fca8 100644
--- a/Astral.ApiServer/Models/Common/ResultModel.cs
+++ b/Astral.ApiServer/Models/Common/ResultModel.cs
@@ -12,8 +12,8 @@ namespace Astral.ApiServer.Models.Common;
public class ResultModel
{
///
- /// Either "success" or "failure".
+ /// Indicate success.
///
- [JsonPropertyName("status")]
- public string Status { get; set; }
+ [JsonPropertyName("success")]
+ public bool Success { get; set; }
}
diff --git a/Astral.ApiServer/Models/TokenGrantResponseModel.cs b/Astral.ApiServer/Models/TokenGrantResponseModel.cs
deleted file mode 100644
index b36bf1f..0000000
--- a/Astral.ApiServer/Models/TokenGrantResponseModel.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-//
-// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
-//
-
-using System.Text.Json.Serialization;
-
-namespace Astral.ApiServer.Models;
-
-///
-/// OAuth Grant Request Response.
-///
-public class TokenGrantResponseModel
-{
- ///
- /// The granted access token.
- ///
- [JsonPropertyName("access_token")]
- public string AccessToken { get; set; }
-
- ///
- /// The granted refresh token.
- ///
- [JsonPropertyName("refresh_token")]
- public string RefreshToken { get; set; }
-
- ///
- /// When it expires (ticks).
- ///
- [JsonPropertyName("expires_in")]
- public long ExpiresIn { get; set; }
-
- ///
- /// Granted token type.
- ///
- [JsonPropertyName("token_type")]
- public string TokenType { get; set; }
-}
diff --git a/Astral.ApiServer/Models/TokenGrantResultModel.cs b/Astral.ApiServer/Models/TokenGrantResultModel.cs
new file mode 100644
index 0000000..331462e
--- /dev/null
+++ b/Astral.ApiServer/Models/TokenGrantResultModel.cs
@@ -0,0 +1,95 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using System.Text.Json.Serialization;
+using Astral.ApiServer.Models.Common;
+using Astral.Core.Extensions;
+using Astral.Services.Dtos;
+
+namespace Astral.ApiServer.Models;
+
+///
+/// OAuth Grant Request Response.
+///
+public class TokenGrantResultModel : ResultModel
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public TokenGrantResultModel()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of to create from.
+ public TokenGrantResultModel(SessionDto sessionDto)
+ {
+ Success = true;
+ AccessToken = sessionDto.AccessToken;
+ CreatedAt = sessionDto.CreatedAt.Ticks;
+ ExpiresIn = sessionDto.AccessTokenExpires.Ticks;
+ RefreshToken = sessionDto.RefreshToken;
+ Scope = sessionDto.Scope.ToString().ToLowerInvariant();
+ AccountId = sessionDto.UserId.ToString();
+ AccountName = sessionDto.UserName;
+ TokenType = "Bearer";
+ AccountRoles = sessionDto.Role.GetAccessibleRoles().Select(role => role.ToString().ToLowerInvariant()).ToList();
+ }
+
+ ///
+ /// The granted access token.
+ ///
+ [JsonPropertyName("access_token")]
+ public string AccessToken { get; set; }
+
+ ///
+ /// When it expires (ticks).
+ ///
+ [JsonPropertyName("expires_in")]
+ public long ExpiresIn { get; set; }
+
+ ///
+ /// The granted refresh token.
+ ///
+ [JsonPropertyName("refresh_token")]
+ public string RefreshToken { get; set; }
+
+ ///
+ /// The session scope.
+ ///
+ [JsonPropertyName("scope")]
+ public string Scope { get; set; }
+
+ ///
+ /// Granted token type.
+ ///
+ [JsonPropertyName("token_type")]
+ public string TokenType { get; set; }
+
+ ///
+ /// When it was created (ticks).
+ ///
+ [JsonPropertyName("created_at")]
+ public long CreatedAt { get; set; }
+
+ ///
+ /// The user's id.
+ ///
+ [JsonPropertyName("account_id")]
+ public string AccountId { get; set; }
+
+ ///
+ /// The user's name.
+ ///
+ [JsonPropertyName("account_name")]
+ public string AccountName { get; set; }
+
+ ///
+ /// The user's roles.
+ ///
+ [JsonPropertyName("account_roles")]
+ public List AccountRoles { get; set; }
+}
diff --git a/Astral.Core/Constants/TokenScope.cs b/Astral.Core/Constants/TokenScope.cs
new file mode 100644
index 0000000..42e807d
--- /dev/null
+++ b/Astral.Core/Constants/TokenScope.cs
@@ -0,0 +1,26 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+namespace Astral.Core.Constants;
+
+///
+/// Token scopes.
+///
+public enum TokenScope
+{
+ ///
+ /// Owner token scope.
+ ///
+ Owner,
+
+ ///
+ /// Domain token scope.
+ ///
+ Domain,
+
+ ///
+ /// Place token scope.
+ ///
+ Place
+}
diff --git a/Astral.Core/Constants/UserRole.cs b/Astral.Core/Constants/UserRole.cs
index b4c40fd..af271ba 100644
--- a/Astral.Core/Constants/UserRole.cs
+++ b/Astral.Core/Constants/UserRole.cs
@@ -7,24 +7,15 @@ namespace Astral.Core.Constants;
///
/// Available user roles.
///
-public static class UserRole
+public enum UserRole
{
///
- /// User role.
+ /// Basic user.
///
- public const string User = "user";
+ User,
///
- /// Administrator role.
+ /// Administrator.
///
- public const string Admin = "admin";
-
- ///
- /// Available values.
- ///
- public static readonly string[] AvailableRoles =
- [
- User,
- Admin
- ];
+ Admin
}
diff --git a/Astral.Core/Constants/UserState.cs b/Astral.Core/Constants/UserState.cs
new file mode 100644
index 0000000..bb695eb
--- /dev/null
+++ b/Astral.Core/Constants/UserState.cs
@@ -0,0 +1,31 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+namespace Astral.Core.Constants;
+
+///
+/// Available user states.
+///
+public enum UserState
+{
+ ///
+ /// Normal activated user.
+ ///
+ Normal,
+
+ ///
+ /// Suspended account.
+ ///
+ Suspended,
+
+ ///
+ /// Banned account.
+ ///
+ Banned,
+
+ ///
+ /// Awaiting email activation.
+ ///
+ AwaitingEmailActivation,
+}
diff --git a/Astral.Core/Entities/RefreshToken.cs b/Astral.Core/Entities/RefreshToken.cs
new file mode 100644
index 0000000..419cfa6
--- /dev/null
+++ b/Astral.Core/Entities/RefreshToken.cs
@@ -0,0 +1,58 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using Astral.Core.Attributes.EntityAnnotation;
+using Astral.Core.Constants;
+
+namespace Astral.Core.Entities;
+
+///
+/// Refresh token entity.
+///
+[TableMapping("refreshTokens")]
+public class RefreshToken
+{
+ ///
+ /// Token identifier.
+ ///
+ [PrimaryKey]
+ [ColumnMapping("token")]
+ public string Token { get; set; }
+
+ ///
+ /// User id the token belongs to.
+ ///
+ [ColumnMapping("userId")]
+ public Guid UserId { get; set; }
+
+ ///
+ /// Ip address of the agent the token granted to.
+ ///
+ [ColumnMapping("ipAddress")]
+ public string IpAddress { get; set; }
+
+ ///
+ /// Session agent role.
+ ///
+ [ColumnMapping("role")]
+ public UserRole Role { get; set; }
+
+ ///
+ /// Session token scope.
+ ///
+ [ColumnMapping("scope")]
+ public TokenScope Scope { get; set; }
+
+ ///
+ /// When the token expires.
+ ///
+ [ColumnMapping("expires")]
+ public DateTime Expires { get; set; }
+
+ ///
+ /// When the token was created.
+ ///
+ [ColumnMapping("createdAt")]
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/Astral.Core/Entities/User.cs b/Astral.Core/Entities/User.cs
index 7497d55..57e9a4a 100644
--- a/Astral.Core/Entities/User.cs
+++ b/Astral.Core/Entities/User.cs
@@ -3,6 +3,7 @@
//
using Astral.Core.Attributes.EntityAnnotation;
+using Astral.Core.Constants;
namespace Astral.Core.Entities;
@@ -55,6 +56,12 @@ public class User
[ColumnMapping("authSalt")]
public string PasswordSalt { get; set; }
+ ///
+ /// State of the user's account.
+ ///
+ [ColumnMapping("userState")]
+ public UserState State { get; set; }
+
///
/// The ip address of the creator.
///
@@ -71,7 +78,7 @@ public class User
/// The user's role.
///
[ColumnMapping("role")]
- public string UserRole { get; set; }
+ public UserRole Role { get; set; }
///
/// Group for this user's connections.
diff --git a/Astral.Core/Extensions/UserRoleExtensions.cs b/Astral.Core/Extensions/UserRoleExtensions.cs
new file mode 100644
index 0000000..a3a109c
--- /dev/null
+++ b/Astral.Core/Extensions/UserRoleExtensions.cs
@@ -0,0 +1,28 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using Astral.Core.Constants;
+
+namespace Astral.Core.Extensions;
+
+///
+/// extensions.
+///
+public static class UserRoleExtensions
+{
+ ///
+ /// Get accessible roles of a user role.
+ ///
+ /// This .
+ /// Collection of accessible .
+ public static UserRole[] GetAccessibleRoles(this UserRole userRole)
+ {
+ return userRole switch
+ {
+ UserRole.User => [UserRole.User],
+ UserRole.Admin => [UserRole.User, UserRole.Admin],
+ _ => []
+ };
+ }
+}
diff --git a/Astral.Core/RepositoryInterfaces/IRefreshTokenRepository.cs b/Astral.Core/RepositoryInterfaces/IRefreshTokenRepository.cs
new file mode 100644
index 0000000..8e5c5b7
--- /dev/null
+++ b/Astral.Core/RepositoryInterfaces/IRefreshTokenRepository.cs
@@ -0,0 +1,19 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using Astral.Core.Entities;
+
+namespace Astral.Core.RepositoryInterfaces;
+
+///
+/// Repository.
+///
+public interface IRefreshTokenRepository : IGenericRepository
+{
+ ///
+ /// Revoke all tokens for the provided user id.
+ ///
+ /// User id.
+ Task RevokeAllForUser(Guid userId);
+}
diff --git a/Astral.DAL/Migrations/2024-12-09.04-users.sql b/Astral.DAL/Migrations/2024-12-09.04-users.sql
index 0f04776..d3f6641 100644
--- a/Astral.DAL/Migrations/2024-12-09.04-users.sql
+++ b/Astral.DAL/Migrations/2024-12-09.04-users.sql
@@ -7,9 +7,10 @@ CREATE TABLE users
email TEXT UNIQUE,
authHash TEXT,
authSalt TEXT,
+ userState SMALLINT DEFAULT NULL,
lastLoggedIn TIMESTAMP DEFAULT NULL,
creatorIp TEXT DEFAULT '0.0.0.0',
- role TEXT DEFAULT 'user' NOT NULL,
+ role SMALLINT DEFAULT 0 NOT NULL,
state SMALLINT DEFAULT 0 NOT NULL,
connectionGroup UUID DEFAULT NULL,
friendGroup UUID DEFAULT NULL,
diff --git a/Astral.DAL/Migrations/2024-12-14.01-refreshTokens.sql b/Astral.DAL/Migrations/2024-12-14.01-refreshTokens.sql
new file mode 100644
index 0000000..f99f93c
--- /dev/null
+++ b/Astral.DAL/Migrations/2024-12-14.01-refreshTokens.sql
@@ -0,0 +1,10 @@
+CREATE TABLE refreshTokens
+(
+ token TEXT UNIQUE PRIMARY KEY NOT NULL,
+ userId UUID REFERENCES users(id) ON DELETE CASCADE,
+ ipAddress TEXT NOT NULL,
+ role SMALLINT NOT NULL,
+ scope SMALLINT NOT NULL,
+ expires TIMESTAMP NOT NULL,
+ createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
+);
diff --git a/Astral.DAL/Repositories/RefreshTokenRepository.cs b/Astral.DAL/Repositories/RefreshTokenRepository.cs
new file mode 100644
index 0000000..019780f
--- /dev/null
+++ b/Astral.DAL/Repositories/RefreshTokenRepository.cs
@@ -0,0 +1,35 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using Astral.Core.Entities;
+using Astral.Core.Infrastructure;
+using Astral.Core.RepositoryInterfaces;
+using Dapper;
+using Injectio.Attributes;
+
+namespace Astral.DAL.Repositories;
+
+///
+[RegisterScoped]
+public class RefreshTokenRepository : BaseRepository, IRefreshTokenRepository
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of .
+ public RefreshTokenRepository(IDbConnectionProvider db)
+ : base(db)
+ {
+ }
+
+ ///
+ public async Task RevokeAllForUser(Guid userId)
+ {
+ await WithDatabaseAsync(async connection => await connection.ExecuteAsync(
+ $"DELETE FROM {Table} WHERE userId = @pUserId", new
+ {
+ pUserId = userId,
+ }));
+ }
+}
diff --git a/Astral.Services/Constants/ClaimIds.cs b/Astral.Services/Constants/ClaimIds.cs
new file mode 100644
index 0000000..3829dc0
--- /dev/null
+++ b/Astral.Services/Constants/ClaimIds.cs
@@ -0,0 +1,31 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+namespace Astral.Services.Constants;
+
+///
+/// Collection of claim ids.
+///
+public static class ClaimIds
+{
+ ///
+ /// User Id claim.
+ ///
+ public const string UserId = "user-id";
+
+ ///
+ /// Username claim.
+ ///
+ public const string UserName = "user-name";
+
+ ///
+ /// User Role claim.
+ ///
+ public const string Role = "role";
+
+ ///
+ /// Token scope.
+ ///
+ public const string Scope = "scope";
+}
diff --git a/Astral.Services/Constants/ServiceErrorCodes.cs b/Astral.Services/Constants/ServiceErrorCodes.cs
index 38547d6..e9b91e0 100644
--- a/Astral.Services/Constants/ServiceErrorCodes.cs
+++ b/Astral.Services/Constants/ServiceErrorCodes.cs
@@ -5,7 +5,7 @@
namespace Astral.Services.Constants;
///
-/// Collection of standard error codes.
+/// Collection of standard error codes.
///
public static class ServiceErrorCodes
{
diff --git a/Astral.Services/Dtos/BaseAuthenticationDto.cs b/Astral.Services/Dtos/BaseAuthenticationDto.cs
new file mode 100644
index 0000000..f4bc6e0
--- /dev/null
+++ b/Astral.Services/Dtos/BaseAuthenticationDto.cs
@@ -0,0 +1,18 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using System.Net;
+
+namespace Astral.Services.Dtos;
+
+///
+/// Common members across authentication dtos.
+///
+public class BaseAuthenticationDto
+{
+ ///
+ /// IP address of the requesting agent.
+ ///
+ public IPAddress IpAddress { get; set; }
+}
diff --git a/Astral.Services/Dtos/AuthenticateUserDto.cs b/Astral.Services/Dtos/PasswordAuthenticateDto.cs
similarity index 60%
rename from Astral.Services/Dtos/AuthenticateUserDto.cs
rename to Astral.Services/Dtos/PasswordAuthenticateDto.cs
index e60e885..6f87a3c 100644
--- a/Astral.Services/Dtos/AuthenticateUserDto.cs
+++ b/Astral.Services/Dtos/PasswordAuthenticateDto.cs
@@ -1,13 +1,15 @@
-//
+//
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
//
+using Astral.Core.Constants;
+
namespace Astral.Services.Dtos;
///
/// Authentication (login) request dto.
///
-public class AuthenticateUserDto
+public class PasswordAuthenticateDto : BaseAuthenticationDto
{
///
/// Agent's username.
@@ -18,4 +20,9 @@ public class AuthenticateUserDto
/// Agent's password.
///
public string Password { get; set; }
+
+ ///
+ /// The scope for the token.
+ ///
+ public TokenScope Scope { get; set; }
}
diff --git a/Astral.Services/Dtos/SessionDto.cs b/Astral.Services/Dtos/SessionDto.cs
new file mode 100644
index 0000000..97c6c9c
--- /dev/null
+++ b/Astral.Services/Dtos/SessionDto.cs
@@ -0,0 +1,58 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using Astral.Core.Constants;
+
+namespace Astral.Services.Dtos;
+
+///
+/// Session information.
+///
+public class SessionDto
+{
+ ///
+ /// User id.
+ ///
+ public Guid UserId { get; set; }
+
+ ///
+ /// User name.
+ ///
+ public string UserName { get; set; }
+
+ ///
+ /// Access token.
+ ///
+ public string AccessToken { get; set; }
+
+ ///
+ /// Refresh token.
+ ///
+ public string RefreshToken { get; set; }
+
+ ///
+ /// Created at.
+ ///
+ public DateTime CreatedAt { get; set; }
+
+ ///
+ /// Access token expiry (UTC).
+ ///
+ public DateTime AccessTokenExpires { get; set; }
+
+ ///
+ /// Refresh token expiry (UTC).
+ ///
+ public DateTime RefreshTokenExpires { get; set; }
+
+ ///
+ /// The session token scope.
+ ///
+ public TokenScope Scope { get; set; }
+
+ ///
+ /// The session user role.
+ ///
+ public UserRole Role { get; set; }
+}
diff --git a/Astral.Services/Exceptions/UserSuspendedException.cs b/Astral.Services/Exceptions/UserSuspendedException.cs
new file mode 100644
index 0000000..663112f
--- /dev/null
+++ b/Astral.Services/Exceptions/UserSuspendedException.cs
@@ -0,0 +1,23 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using System.Net;
+using Astral.Core.Exceptions;
+using Astral.Services.Constants;
+
+namespace Astral.Services.Exceptions;
+
+///
+/// Thrown when the logging in user is suspended.
+///
+public class UserSuspendedException : ServiceException
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public UserSuspendedException()
+ : base(ServiceErrorCodes.SuspendedUser, "You are suspended from logging in", HttpStatusCode.Unauthorized)
+ {
+ }
+}
diff --git a/Astral.Services/Interfaces/IAuthenticationService.cs b/Astral.Services/Interfaces/IAuthenticationService.cs
new file mode 100644
index 0000000..f3bb6ce
--- /dev/null
+++ b/Astral.Services/Interfaces/IAuthenticationService.cs
@@ -0,0 +1,20 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using Astral.Services.Dtos;
+
+namespace Astral.Services.Interfaces;
+
+///
+/// Provide methods to authenticate new sessions.
+///
+public interface IAuthenticationService
+{
+ ///
+ /// Authenticate using username/password pair.
+ ///
+ /// Instance of .
+ /// Instance of .
+ Task AuthenticateSession(PasswordAuthenticateDto passwordAuthenticateDto);
+}
diff --git a/Astral.Services/Profiles/AutomapperProfile.cs b/Astral.Services/Profiles/AutomapperProfile.cs
index 51cc1a5..fef364d 100644
--- a/Astral.Services/Profiles/AutomapperProfile.cs
+++ b/Astral.Services/Profiles/AutomapperProfile.cs
@@ -22,7 +22,7 @@ public class AutomapperProfile : Profile
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id))
.ForMember(dest => dest.RegistrationDate, opt => opt.MapFrom(src => src.CreatedAt))
.ForMember(dest => dest.RegistrationDateTicks, opt => opt.MapFrom(src => src.CreatedAt.Ticks))
- .ForMember(dest => dest.UserRole, opt => opt.MapFrom(src => src.UserRole));
+ .ForMember(dest => dest.UserRole, opt => opt.MapFrom(src => src.Role.ToString()));
CreateMap()
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id))
diff --git a/Astral.Services/Services/AuthenticationService.cs b/Astral.Services/Services/AuthenticationService.cs
new file mode 100644
index 0000000..f9546d9
--- /dev/null
+++ b/Astral.Services/Services/AuthenticationService.cs
@@ -0,0 +1,178 @@
+//
+// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
+//
+
+using System.IdentityModel.Tokens.Jwt;
+using System.Net;
+using System.Security.Claims;
+using System.Text;
+using Astral.Core.Constants;
+using Astral.Core.Entities;
+using Astral.Core.Extensions;
+using Astral.Core.Infrastructure;
+using Astral.Core.Options;
+using Astral.Core.RepositoryInterfaces;
+using Astral.Services.Constants;
+using Astral.Services.Dtos;
+using Astral.Services.Exceptions;
+using Astral.Services.Interfaces;
+using FluentValidation;
+using Injectio.Attributes;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Astral.Services.Services;
+
+///
+[RegisterScoped]
+public class AuthenticationService : IAuthenticationService
+{
+ private const int RefreshTokenLength = 128;
+
+ private readonly ICryptographyService _cryptographyService;
+ private readonly ITransactionProvider _transactionProvider;
+ private readonly IUserRepository _userRepository;
+ private readonly IRefreshTokenRepository _refreshTokenRepository;
+ private readonly IValidator _passwordAuthValidator;
+ private readonly JwtOptions _jwtOptions;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of .
+ /// Instance of .
+ /// Instance of .
+ /// Instance of .
+ /// Instance of .
+ /// Instance of .
+ public AuthenticationService(
+ ICryptographyService cryptographyService,
+ ITransactionProvider transactionProvider,
+ IUserRepository userRepository,
+ IRefreshTokenRepository refreshTokenRepository,
+ IValidator passwordAuthValidator,
+ IOptions jwtOptions)
+ {
+ _cryptographyService = cryptographyService;
+ _transactionProvider = transactionProvider;
+ _userRepository = userRepository;
+ _refreshTokenRepository = refreshTokenRepository;
+ _passwordAuthValidator = passwordAuthValidator;
+ _jwtOptions = jwtOptions.Value;
+ }
+
+ ///
+ public async Task AuthenticateSession(PasswordAuthenticateDto passwordAuthenticateDto)
+ {
+ await _passwordAuthValidator.ValidateAndThrowAsync(passwordAuthenticateDto);
+
+ // Find user from provided username.
+ var user = await _userRepository.FindByUsername(passwordAuthenticateDto.Username);
+ if (user is null)
+ {
+ throw new InvalidCredentialsException();
+ }
+
+ if (!_cryptographyService.VerifyPassword(
+ passwordAuthenticateDto.Password,
+ user.PasswordSalt,
+ user.PasswordHash))
+ {
+ // TODO store failed logins in future
+ throw new InvalidCredentialsException();
+ }
+
+ switch (user.State)
+ {
+ case UserState.Suspended:
+ throw new UserSuspendedException();
+ case UserState.Banned:
+ throw new UserBannedException();
+ case UserState.AwaitingEmailActivation:
+ throw new UserNotActivatedException();
+ }
+
+ using var transaction = await _transactionProvider.BeginTransactionAsync();
+
+ // Record last logged in.
+ // TODO store successful login in future
+ user.LastLoggedIn = DateTime.UtcNow;
+ await _userRepository.UpdateAsync(user);
+
+ // Generate token.
+ var result = await GenerateAccessTokens(user, passwordAuthenticateDto.Scope, passwordAuthenticateDto.IpAddress);
+
+ transaction.Commit();
+ return result;
+ }
+
+ ///
+ /// Generate a new access token and refresh token for user.
+ ///
+ /// Instance of .
+ /// Instance of .
+ /// The IP address of the agent requesting a user access token.
+ /// Instance of .
+ private async Task GenerateAccessTokens(
+ User user,
+ TokenScope scope,
+ IPAddress ipAddress)
+ {
+ var handler = new JwtSecurityTokenHandler();
+
+ var claims = new List
+ {
+ new (ClaimIds.UserId, user.Id.ToString()),
+ new (ClaimIds.UserName, user.Username),
+ new (ClaimIds.Role, user.Role.ToString()),
+ new (ClaimIds.Scope, scope.ToString()),
+ };
+
+ var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_jwtOptions.SecretKey));
+
+ var accessExpires = _jwtOptions.AccessTokenExpirationDateTime();
+ var refreshExpires = _jwtOptions.RefreshTokenExpirationDateTime();
+
+ var descriptor = new SecurityTokenDescriptor
+ {
+ Subject = new ClaimsIdentity(claims),
+ Expires = accessExpires,
+ SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature),
+ Audience = _jwtOptions.Audience,
+ Issuer = _jwtOptions.Issuer,
+ };
+
+ var result = new SessionDto();
+
+ var token = handler.CreateToken(descriptor);
+ result.UserId = user.Id;
+ result.UserName = user.Username;
+ result.CreatedAt = DateTime.UtcNow;
+ result.AccessToken = handler.WriteToken(token);
+ result.RefreshToken = _cryptographyService.GenerateRandomString(RefreshTokenLength);
+ result.AccessTokenExpires = accessExpires;
+ result.RefreshTokenExpires = refreshExpires;
+ result.Scope = scope;
+ result.Role = user.Role;
+
+ // Make sure the refresh token is unique.
+ while (await _refreshTokenRepository.FindByIdAsync(result.RefreshToken) is not null)
+ {
+ result.RefreshToken = _cryptographyService.GenerateRandomString(RefreshTokenLength);
+ }
+
+ // Store refresh token.
+ await _refreshTokenRepository.AddAsync(new RefreshToken()
+ {
+ Token = result.RefreshToken,
+ UserId = user.Id,
+ Role = user.Role,
+ Scope = scope,
+ Expires = refreshExpires,
+ IpAddress = ipAddress.ToString(),
+ CreatedAt = DateTime.UtcNow,
+ });
+
+ return result;
+ }
+}
diff --git a/Astral.Services/Services/InitialUserService.cs b/Astral.Services/Services/InitialUserService.cs
index c357b02..ee5d2c7 100644
--- a/Astral.Services/Services/InitialUserService.cs
+++ b/Astral.Services/Services/InitialUserService.cs
@@ -69,7 +69,7 @@ public class InitialUserService : IInitialUserService
// Promote user to admin.
var userEntity = await _userRepository.FindByIdAsync(newUser.Id);
- userEntity.UserRole = UserRole.Admin;
+ userEntity.Role = UserRole.Admin;
await _userRepository.UpdateAsync(userEntity);
diff --git a/Astral.Services/Services/UserService.cs b/Astral.Services/Services/UserService.cs
index 6828bd1..3d0b0ba 100644
--- a/Astral.Services/Services/UserService.cs
+++ b/Astral.Services/Services/UserService.cs
@@ -98,7 +98,7 @@ public class UserService : IUserService
EmailAddress = createUser.Email,
CreatedAt = DateTime.UtcNow,
CreatorIp = createUser.IpAddress.ToString(),
- UserRole = UserRole.User
+ Role = UserRole.User
};
var salt = _cryptographyService.GenerateSalt();
@@ -115,6 +115,11 @@ public class UserService : IUserService
if (!_registrationConfiguration.RequireEmailActivation || createUser.ActivateImmediately)
{
+ user.State = UserState.Normal;
+ }
+ else
+ {
+ user.State = UserState.AwaitingEmailActivation;
}
using var transaction = await _transactionProvider.BeginTransactionAsync();
diff --git a/Astral.Services/Validators/AuthenticateUserValidator.cs b/Astral.Services/Validators/AuthenticateUserValidator.cs
index bbb15b4..f28bfc8 100644
--- a/Astral.Services/Validators/AuthenticateUserValidator.cs
+++ b/Astral.Services/Validators/AuthenticateUserValidator.cs
@@ -9,10 +9,10 @@ using Injectio.Attributes;
namespace Astral.Services.Validators;
///
-/// Validation for .
+/// Validation for .
///
[RegisterScoped]
-public class AuthenticateUserValidator : AbstractValidator
+public class AuthenticateUserValidator : AbstractValidator
{
private const int MinimumUsernameLength = 4;