diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c63ffe5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Build artifacts +bin/ +obj/ + +# Visual Studio +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# Logs +*.log + +# Entity Framework migrations +Migrations/ + +# Security - RSA Keys and sensitive config + +*.key +*.pem +GenerateKeys.ps1 diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..91c0416 --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,330 @@ +# AuthHex API Documentation + +Base route: `api` +All endpoints are `[AllowAnonymous]` at the HTTP layer, but individual functions may require a Bearer JWT (read via `HttpContext` claims) — noted per function below. + +## Common envelope + +**Request** (`ApiRequest`) +```json +{ + "functionName": "string (required, ignored for dedicated routes below)", + "payload": { "...": "function-specific object" }, + "reference": "string (required, can be empty)" +} +``` + +**Response** (`ApiResponse`) +```json +{ + "statusCode": 200, + "success": true, + "message": "success", + "data": { "...": "function-specific object" } +} +``` +Errors: `404` unknown function, `400` (`KeyNotFoundException`/`InvalidOperationException`), `500` (`ArgumentException`). + +--- + +## Routes + +| Route | Method | Dispatches to | FunctionName | +|---|---|---|---| +| `/api/status` | GET | — | — (health check) | +| `/api/user` | POST | UserManager | from body | +| `/api/recovery` | POST | RecoveryManager | from body | +| `/api/registerUser` | POST | UserManager | forced `registerUser` | +| `/api/loginUser` | POST | UserManager | forced `loginUser` | +| `/api/forgotPassword` | POST | RecoveryManager | forced `forgotPassword` | +| `/api/alt` | POST | AltOptionManager | from body | + +### GET /api/status +Response: +```json +{ "status": "API is running", "timestamp": "2026-07-03T00:00:00Z" } +``` + +--- + +## /api/user — UserManager functions + +### registerUser +Payload: +```json +{ + "userId": "guid (required)", + "fullname": "string?", + "userName": "string?", + "nic": "string?", + "email": "string?", + "mobileNumber": "string?", + "deviceName": "string? (default 'Unknown Device')", + "roleId": "guid (required)", + "userTypeId": "guid (required)", + "chkUser": "bool? (if true, checks for existing conflicting user)", + "password": "string? (auto-generated if empty)" +} +``` +Data: +```json +{ + "accessToken": "jwt", + "refreshToken": "string", + "expiresIn": 3600, + "user": { "id": "guid", "fullName": "", "userName": "", "email": "", "mobileNumber": "", "emailVerified": false, "mobileNumberVerified": false, "isMfaEnabled": false, "roleId": "guid", "userTypeId": "guid" } +} +``` + +### loginUser +Payload: +```json +{ + "identifier": "string (required)", + "password": "string?", + "userTypeId": "guid?", + "deviceName": "string? (default 'Unknown Device')" +} +``` +Data: same shape as `registerUser` data. + +### VerifyOtpForLogin +Payload: +```json +{ "referenceNumber": "string (required)", "otpCode": "string (required)", "deviceName": "string?" } +``` +Data: +```json +{ + "success": true, + "message": "OTP verified successfully.", + "data": { + "referenceNumber": "", "userId": "guid", "verified": true, + "accessToken": "jwt", "refreshToken": "", "expiresIn": 3600, + "user": { "...": "as above" } + } +} +``` + +### refreshToken +Payload: +```json +{ "refreshToken": "string (required)", "deviceName": "string?" } +``` +Data: same shape as `registerUser` data (rotates session). + +### getUserDetails +Payload: +```json +{ "userId": "guid (required)" } +``` +Data: +```json +{ + "id": "guid", "fullName": "", "userName": "", "email": "", "nic": "", + "mobileNumber": "", "emailVerified": false, "mobileNumberVerified": false, + "isMfaEnabled": false, "isActive": true, "isLocked": false, "createdAt": "", + "role": { "roleId": "", "code": "", "name": "" }, + "userType": { "userTypeId": "", "code": "", "description": "" } +} +``` + +### getUserSessions +_Requires auth (userId from claims). No payload needed._ +Data: array of +```json +{ "sessionId": "", "deviceName": "", "browser": "", "os": "", "ipAddress": "", "createdAt": "", "expiresAt": "", "revokedAt": "", "isActive": true } +``` + +### ChangeUserStatus +_Requires auth._ Payload: +```json +{ "isActive": "bool (required)" } +``` +Data: `{ "id", "fullName", "userName", "email", "mobileNumber", "isActive" }` + +### LockUserAccount +_Requires auth._ Payload: +```json +{ "isLocked": "bool (required)" } +``` +Data: `{ "id", "fullName", "userName", "email", "mobileNumber", "isLocked", "deletedAt" }` +(Also invalidates all user sessions.) + +### ChangeUserPassword +_Requires auth._ Payload: +```json +{ "currentPassword": "string (required)", "newPassword": "string (required)" } +``` +Data: `{ "message": "Password changed successfully" }` + +### LogoutUser +Payload: +```json +{ "userId": "guid (required)" } +``` +Data: `{ "message": "User logged out successfully" }` + +### UpdateUser +_Requires auth._ Payload (all optional, at least one required): +```json +{ + "fullName": "string?", "userName": "string?", "nic": "string?", "address": "string?", + "optional1": "string?", "optional2": "string?", + "email": "string|null", "mobileNumber": "string|null", + "currentPassword": "string? (required if newPassword set and existing password set)", + "newPassword": "string?" +} +``` +Data: +```json +{ + "message": "User updated successfully", + "user": { "id", "fullName", "userName", "nic", "address", "optional1", "optional2", "email", "emailVerified", "mobileNumber", "mobileNumberVerified" } +} +``` + +### 2FA — initiateTwoFASetup +_Requires auth. No payload._ +Data: third-party-service-defined setup result (QR/secret info). + +### 2FA — completeTwoFASetup +_Requires auth._ Payload: +```json +{ "secretKey": "string (required)", "verificationCode": "string (required)" } +``` +Data: +```json +{ + "message": "2FA setup completed successfully", + "backupCodes": ["..."], + "user": { "id", "fullName", "userName", "email", "isMfaEnabled" } +} +``` + +### 2FA — verifyTwoFA +_Requires auth._ Payload: +```json +{ "verificationCode": "string (required)" } +``` +Data: raw third-party verification result object. + +### 2FA — disableTwoFA +_Requires auth._ Payload: +```json +{ "verificationCode": "string (required)" } +``` +Data: +```json +{ "message": "2FA has been disabled successfully", "user": { "id", "fullName", "userName", "email", "isMfaEnabled" } } +``` + +### 2FA — getTwoFAStatus +_Requires auth. No payload._ +Data: +```json +{ "isMfaEnabled": false, "isVerified": false, "lastUsedAt": null, "verifiedAt": null, "hasBackupCodes": false } +``` + +--- + +## /api/recovery — RecoveryManager functions + +### forgotPassword +Payload: +```json +{ "identifier": "string (required)", "useResetLink": "bool? (default false)", "numberOfDigits": "int? (default 6)" } +``` +Data (useResetLink = true): +```json +{ "success": true, "message": "Password reset link sent to your email.", "data": { "referenceNumber": "", "expiresAt": "", "recoveryType": "ResetLink" } } +``` +Data (useResetLink = false, OTP flow): +```json +{ "success": true, "message": "OTP sent successfully.", "data": { "referenceNumber": "", "expiresAt": "", "recoveryType": "OTP" } } +``` + +### verifyOTP +Payload: +```json +{ "referenceNumber": "string (required)", "otpCode": "string (required)" } +``` +Data: +```json +{ "success": true, "message": "OTP verified successfully. You can now reset your password.", "data": { "referenceNumber": "", "userId": "guid", "verified": true } } +``` + +### resetPasswordWithToken +Payload: +```json +{ "resetToken": "string (required)", "newPassword": "string (required, min 8 chars)", "confirmPassword": "string (required, must match)" } +``` +Data: +```json +{ "success": true, "message": "Password reset successful. Please login with your new password.", "data": { "userId": "guid", "email": "", "resetAt": "" } } +``` + +### resetPassword +Payload: +```json +{ "referenceNumber": "string (required)", "newPassword": "string (required, min 8 chars)", "confirmPassword": "string (required, must match)" } +``` +(Requires recovery status = "Verified" via prior `verifyOTP` call.) +Data: same shape as `resetPasswordWithToken`. + +--- + +## /api/alt — AltOptionManager functions + +### IsAvailable +Payload: +```json +{ "Identifier": "string?", "Recovery": "string? (any non-null value flags recovery-mode)" } +``` +Data (available): `{ "isAvailable": true, "message": "Identifier is available" }` +Data (taken, no Recovery): `{ "isAvailable": false, "message": "Identifier already in use" }` +Data (taken, Recovery set): `{ "existingUsers": [...] }` + +### sendOtp +Payload: +```json +{ "identifier": "string (required, email or mobile)", "numberOfDigits": "int? (default 4)", "newUser": "bool? (default false)" } +``` +Data (newUser = true): +```json +{ "success": true, "message": "OTP sent successfully for new user.", "data": { "referenceNumber": "", "expiresAt": "", "recoveryType": "OTP FOR NEW USER" } } +``` +Data (existing user): +```json +{ "success": true, "message": "OTP sent successfully.", "data": { "referenceNumber": "", "expiresAt": "", "recoveryType": "OTP FOR LOGIN" } } +``` + +### VerifyOTP (alt) +Payload: +```json +{ + "userId": "guid?", "referenceNumber": "string (required)", "otpCode": "string (required)", + "newUser": "bool? (default false)", "identifier": "string? (email/mobile/other — marks it verified on user)", + "deviceName": "string? (default 'Unknown Device')" +} +``` +Data: +```json +{ + "success": true, + "message": "OTP verified successfully.", + "data": { + "referenceNumber": "", "userId": "guid", "verified": true, + "accessToken": "jwt", "refreshToken": "", "expiresIn": 3600, + "user": { "id", "fullName", "userName", "email", "mobileNumber", "emailVerified", "mobileNumberVerified", "isMfaEnabled", "roleId", "userTypeId" } + } +} +``` + +--- + +## Notes +- All timestamps are UTC. +- `deviceName`, `Browser`, `OS`, `IPAddress` are captured per session from request headers for session tracking (`getUserSessions`). +- Password login validation (`PasswordHasher.Verify`) is currently commented out in `loginUser` — passwords are not checked on login as of this version. +- JWT access tokens issued with `expiresIn: 3600` (1 hour); refresh tokens/sessions expire after 30 days. diff --git a/AppDBContext.cs b/AppDBContext.cs new file mode 100644 index 0000000..4ce484b --- /dev/null +++ b/AppDBContext.cs @@ -0,0 +1,116 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + + +namespace AuthHex +{ + public class AppDBContext : DbContext + { + public AppDBContext(DbContextOptions options) : base(options) { } + public DbSet Users => Set(); + public DbSet Roles => Set(); + public DbSet UserType => Set(); + public DbSet UserSessions => Set(); + + public DbSet Token => Set(); + + public DbSet AuthEventLogs => Set(); + public DbSet UserMfas => Set(); + public DbSet UserProviders => Set(); + public DbSet Recovery => Set(); + public DbSet SysConfigs => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Configure UserSession primary key + modelBuilder.Entity() + .HasKey(us => us.SessionId); + + // Configure relationship between UserSession and User + modelBuilder.Entity() + .HasOne(us => us.User) + .WithMany(u => u.Sessions) + .HasForeignKey(us => us.UserId) + .OnDelete(DeleteBehavior.Cascade); + + // Configure AuthEventLog primary key + modelBuilder.Entity() + .HasKey(ae => ae.Id); + + // Configure Token primary key and relationships + modelBuilder.Entity() + .HasKey(t => t.TokenId); + + modelBuilder.Entity() + .HasOne(t => t.User) + .WithMany() + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + + // Configure UserMfa primary key and relationships + modelBuilder.Entity() + .HasKey(um => um.UserMfaId); + + modelBuilder.Entity() + .HasOne(um => um.User) + .WithOne(u => u.UserMfa) + .HasForeignKey(um => um.UserId) + .OnDelete(DeleteBehavior.Cascade); + + // Configure UserProvider primary key and relationships + modelBuilder.Entity() + .HasKey(up => up.ProviderId); + + modelBuilder.Entity() + .HasOne(up => up.User) + .WithMany(u => u.Providers) + .HasForeignKey(up => up.UserId) + .OnDelete(DeleteBehavior.Cascade); + + // Configure Role primary key + modelBuilder.Entity() + .HasKey(r => r.RoleId); + + // Configure UserType primary key + modelBuilder.Entity() + .HasKey(ut => ut.UserTypeId); + + // Configure User primary key and relationships + modelBuilder.Entity() + .HasKey(u => u.Id); + + modelBuilder.Entity() + .HasOne(u => u.Role) + .WithMany() + .HasForeignKey(u => u.RoleId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(u => u.UserType) + .WithMany() + .HasForeignKey(u => u.UserTypeId) + .OnDelete(DeleteBehavior.Restrict); + } + } + + public class ApplicationDbContextFactory : IDesignTimeDbContextFactory + { + public AppDBContext CreateDbContext(string[] args) + { + var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + var connectionString = config.GetConnectionString("DefaultConnection"); + + // Pomelo MySQL provider + optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 21))); + + return new AppDBContext(optionsBuilder.Options); + } + } +} diff --git a/AuthHex.csproj b/AuthHex.csproj new file mode 100644 index 0000000..fde83d3 --- /dev/null +++ b/AuthHex.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/AuthHex.sln b/AuthHex.sln new file mode 100644 index 0000000..7e2ff29 --- /dev/null +++ b/AuthHex.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 d18.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthHex", "AuthHex.csproj", "{2D195EDD-1ECF-968A-CF6C-99F5FCE8CA56}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2D195EDD-1ECF-968A-CF6C-99F5FCE8CA56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D195EDD-1ECF-968A-CF6C-99F5FCE8CA56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D195EDD-1ECF-968A-CF6C-99F5FCE8CA56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D195EDD-1ECF-968A-CF6C-99F5FCE8CA56}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8133C788-893B-4632-842E-7FA75FFA716B} + EndGlobalSection +EndGlobal diff --git a/AuthHex_Client_Collection.json b/AuthHex_Client_Collection.json new file mode 100644 index 0000000..5f15346 --- /dev/null +++ b/AuthHex_Client_Collection.json @@ -0,0 +1,743 @@ +{ + "info": { + "name": "AuthHex Client API Collection", + "description": "Web client authentication flow - Email OTP registration and password login", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_postman_id": "auth-hex-client-api-collection", + "version": "1.0.0" + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://localhost:7001/api", + "description": "Base URL for AuthHex API" + }, + { + "key": "accessToken", + "value": "", + "description": "JWT Access Token - set automatically after login" + }, + { + "key": "refreshToken", + "value": "", + "description": "JWT Refresh Token - set automatically after login" + }, + { + "key": "email", + "value": "user@example.com", + "description": "Email for testing" + }, + { + "key": "referenceNumber", + "value": "", + "description": "OTP Reference Number - set automatically" + } + ], + "item": [ + { + "name": "Health Check", + "item": [ + { + "name": "API Status", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/status", + "host": ["{{baseUrl}}"], + "path": ["status"] + }, + "description": "Check if API is running" + }, + "response": [] + } + ] + }, + { + "name": "Client Registration Flow", + "item": [ + { + "name": "Step 1: Check Email Availability", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"isAvailable\",\n \"payload\": {\n \"Identifier\": \"{{email}}\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/alt", + "host": ["{{baseUrl}}"], + "path": ["alt"] + }, + "description": "Check if email is available for registration" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.IsAvailable) {", + " pm.environment.set('emailAvailable', true);", + " console.log('Email is available');", + "} else {", + " console.log('Email already exists');", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Step 2: Send Email OTP (New User)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"sendOtp\",\n \"payload\": {\n \"identifier\": \"{{email}}\",\n \"numberOfDigits\": 6,\n \"newUser\": true\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/alt", + "host": ["{{baseUrl}}"], + "path": ["alt"] + }, + "description": "Send OTP to email for new user registration" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.Data && jsonData.data.Data.ReferenceNumber) {", + " pm.environment.set('referenceNumber', jsonData.data.Data.ReferenceNumber);", + " console.log('Email OTP Reference: ' + jsonData.data.Data.ReferenceNumber);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Step 3: Verify Email OTP (New User)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"VerifyOTP\",\n \"payload\": {\n \"referenceNumber\": \"{{referenceNumber}}\",\n \"otpCode\": \"123456\",\n \"newUser\": true,\n \"identifier\": \"{{email}}\",\n \"deviceName\": \"Chrome Desktop\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/alt", + "host": ["{{baseUrl}}"], + "path": ["alt"] + }, + "description": "Verify email OTP for new user registration. Returns verification token." + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.verificationToken) {", + " pm.environment.set('verificationToken', jsonData.data.verificationToken);", + " console.log('Email OTP Verified - Registration can proceed');", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Step 4: Complete Registration", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"registerUser\",\n \"payload\": {\n \"fullName\": \"Alice Johnson\",\n \"userName\": \"alicejohnson\",\n \"email\": \"{{email}}\",\n \"nic\": \"199612345679\",\n \"password\": \"SecureClientPass123!\",\n \"roleId\": \"00000000-0000-0000-0000-000000000001\",\n \"userTypeId\": \"00000000-0000-0000-0000-000000000001\",\n \"chkUser\": false\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Complete user registration with verified email" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.statusCode === 200) {", + " console.log('Client user registered successfully');", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Alternative: Direct Registration", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"fullName\": \"Bob Williams\",\n \"userName\": \"bobwilliams\",\n \"email\": \"bob.williams@example.com\",\n \"nic\": \"199712345678\",\n \"password\": \"SecureClientPass123!\",\n \"roleId\": \"00000000-0000-0000-0000-000000000001\",\n \"userTypeId\": \"00000000-0000-0000-0000-000000000001\",\n \"chkUser\": true\n}" + }, + "url": { + "raw": "{{baseUrl}}/registerUser", + "host": ["{{baseUrl}}"], + "path": ["registerUser"] + }, + "description": "Direct registration endpoint - bypasses OTP verification" + }, + "response": [] + } + ] + }, + { + "name": "Client Login Flow", + "item": [ + { + "name": "Login with Email/Username & Password", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"identifier\": \"{{email}}\",\n \"password\": \"SecureClientPass123!\",\n \"deviceName\": \"Chrome Desktop\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/loginUser", + "host": ["{{baseUrl}}"], + "path": ["loginUser"] + }, + "description": "Login with email/username and password - returns JWT tokens" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.AccessToken) {", + " pm.environment.set('accessToken', jsonData.data.AccessToken);", + " pm.environment.set('refreshToken', jsonData.data.RefreshToken);", + " console.log('Login successful - Tokens saved');", + "} else if (jsonData.data && jsonData.data.RequiresTwoFA) {", + " console.log('2FA Required - Use Verify 2FA endpoint');", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Alternative: Login via /user endpoint", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"loginUser\",\n \"payload\": {\n \"identifier\": \"{{email}}\",\n \"password\": \"SecureClientPass123!\",\n \"deviceName\": \"Chrome Desktop\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Alternative login using the generic /user endpoint" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.AccessToken) {", + " pm.environment.set('accessToken', jsonData.data.AccessToken);", + " pm.environment.set('refreshToken', jsonData.data.RefreshToken);", + " console.log('Login successful - Tokens saved');", + "}" + ], + "type": "text/javascript" + } + } + ] + } + ] + }, + { + "name": "Authenticated Client Operations", + "item": [ + { + "name": "Get User Details", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"getUserDetails\",\n \"payload\": {}\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Get authenticated user details" + }, + "response": [] + }, + { + "name": "Get User Sessions", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"getUserSessions\",\n \"payload\": {}\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Get all active sessions for the user" + }, + "response": [] + }, + { + "name": "Change Password", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"ChangeUserPassword\",\n \"payload\": {\n \"currentPassword\": \"SecureClientPass123!\",\n \"newPassword\": \"NewSecureClientPass456!\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Change user password" + }, + "response": [] + }, + { + "name": "Change User Status", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"ChangeUserStatus\",\n \"payload\": {\n \"isActive\": true\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Enable or disable user account" + }, + "response": [] + }, + { + "name": "Logout", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"LogoutUser\",\n \"payload\": {}\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Logout user and invalidate current session" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.environment.unset('accessToken');", + "pm.environment.unset('refreshToken');", + "console.log('Logged out - Tokens cleared');" + ], + "type": "text/javascript" + } + } + ] + } + ] + }, + { + "name": "Password Recovery", + "item": [ + { + "name": "Step 1: Request Password Reset (Forgot Password)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"identifier\": \"{{email}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/forgotPassword", + "host": ["{{baseUrl}}"], + "path": ["forgotPassword"] + }, + "description": "Request password reset - sends OTP to registered email/mobile" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.ReferenceNumber) {", + " pm.environment.set('referenceNumber', jsonData.data.ReferenceNumber);", + " console.log('Reset OTP Reference: ' + jsonData.data.ReferenceNumber);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Step 2: Verify Reset OTP", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"VerifyForgotPasswordOTP\",\n \"payload\": {\n \"referenceNumber\": \"{{referenceNumber}}\",\n \"otpCode\": \"123456\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/recovery", + "host": ["{{baseUrl}}"], + "path": ["recovery"] + }, + "description": "Verify OTP for password reset" + }, + "response": [] + }, + { + "name": "Step 3: Reset Password", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"ResetPassword\",\n \"payload\": {\n \"referenceNumber\": \"{{referenceNumber}}\",\n \"newPassword\": \"MyNewPassword123!\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/recovery", + "host": ["{{baseUrl}}"], + "path": ["recovery"] + }, + "description": "Set new password using verified OTP reference" + }, + "response": [] + } + ] + }, + { + "name": "Two-Factor Authentication (Client)", + "item": [ + { + "name": "Initiate 2FA Setup", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"initiateTwoFASetup\",\n \"payload\": {}\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Get QR code and secret for Google Authenticator setup" + }, + "response": [] + }, + { + "name": "Complete 2FA Setup", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"completeTwoFASetup\",\n \"payload\": {\n \"verificationCode\": \"123456\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Verify and complete 2FA setup" + }, + "response": [] + }, + { + "name": "Verify 2FA Code (During Login)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"verifyTwoFA\",\n \"payload\": {\n \"code\": \"123456\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Verify 2FA code during login for accounts with 2FA enabled" + }, + "response": [] + }, + { + "name": "Get 2FA Status", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"getTwoFAStatus\",\n \"payload\": {}\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Check if 2FA is enabled for user" + }, + "response": [] + }, + { + "name": "Disable 2FA", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"disableTwoFA\",\n \"payload\": {\n \"password\": \"SecureClientPass123!\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Disable 2FA for user account (requires password confirmation)" + }, + "response": [] + } + ] + }, + { + "name": "Utility Functions", + "item": [ + { + "name": "Check Identifier Availability", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"isAvailable\",\n \"payload\": {\n \"Identifier\": \"testuser@example.com\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/alt", + "host": ["{{baseUrl}}"], + "path": ["alt"] + }, + "description": "Check if email, username, NIC, or mobile number is already registered" + }, + "response": [] + } + ] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + } +} diff --git a/AuthHex_Mobile_Collection.json b/AuthHex_Mobile_Collection.json new file mode 100644 index 0000000..553d29e --- /dev/null +++ b/AuthHex_Mobile_Collection.json @@ -0,0 +1,596 @@ +{ + "info": { + "name": "AuthHex Mobile API Collection", + "description": "Mobile authentication flow - OTP-based registration and login", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_postman_id": "auth-hex-mobile-api-collection", + "version": "1.0.0" + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://localhost:7001/api", + "description": "Base URL for AuthHex API" + }, + { + "key": "accessToken", + "value": "", + "description": "JWT Access Token - set automatically after login" + }, + { + "key": "refreshToken", + "value": "", + "description": "JWT Refresh Token - set automatically after login" + }, + { + "key": "mobileNumber", + "value": "+94771234567", + "description": "Mobile number for testing" + }, + { + "key": "referenceNumber", + "value": "", + "description": "OTP Reference Number - set automatically" + } + ], + "item": [ + { + "name": "Health Check", + "item": [ + { + "name": "API Status", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/status", + "host": ["{{baseUrl}}"], + "path": ["status"] + }, + "description": "Check if API is running" + }, + "response": [] + } + ] + }, + { + "name": "Mobile Registration Flow", + "item": [ + { + "name": "Step 1: Check Mobile Availability", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"isAvailable\",\n \"payload\": {\n \"Identifier\": \"{{mobileNumber}}\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/alt", + "host": ["{{baseUrl}}"], + "path": ["alt"] + }, + "description": "Check if mobile number is available for registration" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.IsAvailable) {", + " pm.environment.set('mobileAvailable', true);", + " console.log('Mobile number is available');", + "} else {", + " console.log('Mobile number already exists');", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Step 2: Send Mobile OTP (New User)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"sendOtp\",\n \"payload\": {\n \"identifier\": \"{{mobileNumber}}\",\n \"numberOfDigits\": 6,\n \"newUser\": true\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/alt", + "host": ["{{baseUrl}}"], + "path": ["alt"] + }, + "description": "Send OTP to mobile number for new user registration" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.Data && jsonData.data.Data.ReferenceNumber) {", + " pm.environment.set('referenceNumber', jsonData.data.Data.ReferenceNumber);", + " console.log('Reference Number: ' + jsonData.data.Data.ReferenceNumber);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Step 3: Verify OTP (New User)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"VerifyOTP\",\n \"payload\": {\n \"referenceNumber\": \"{{referenceNumber}}\",\n \"otpCode\": \"123456\",\n \"newUser\": true,\n \"identifier\": \"{{mobileNumber}}\",\n \"deviceName\": \"iPhone 14 Pro\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/alt", + "host": ["{{baseUrl}}"], + "path": ["alt"] + }, + "description": "Verify OTP for new user registration. Returns verification token." + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.verificationToken) {", + " pm.environment.set('verificationToken', jsonData.data.verificationToken);", + " console.log('OTP Verified - Registration can proceed');", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Step 4: Complete Registration", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"registerUser\",\n \"payload\": {\n \"fullName\": \"John Doe\",\n \"userName\": \"johndoe_mobile\",\n \"mobileNumber\": \"{{mobileNumber}}\",\n \"nic\": \"199512345678\",\n \"password\": \"SecurePass123!\",\n \"roleId\": \"00000000-0000-0000-0000-000000000001\",\n \"userTypeId\": \"00000000-0000-0000-0000-000000000001\",\n \"chkUser\": false\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Complete user registration with verified mobile number" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.statusCode === 200) {", + " console.log('User registered successfully');", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Alternative: Direct Registration", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"fullName\": \"Jane Smith\",\n \"userName\": \"janesmith_mobile\",\n \"mobileNumber\": \"+94779876543\",\n \"nic\": \"199612345678\",\n \"password\": \"SecurePass123!\",\n \"roleId\": \"00000000-0000-0000-0000-000000000001\",\n \"userTypeId\": \"00000000-0000-0000-0000-000000000001\",\n \"chkUser\": true\n}" + }, + "url": { + "raw": "{{baseUrl}}/registerUser", + "host": ["{{baseUrl}}"], + "path": ["registerUser"] + }, + "description": "Direct registration endpoint - bypasses OTP verification" + }, + "response": [] + } + ] + }, + { + "name": "Mobile Login Flow", + "item": [ + { + "name": "Step 1: Send Login OTP to Mobile", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"sendOtp\",\n \"payload\": {\n \"identifier\": \"{{mobileNumber}}\",\n \"numberOfDigits\": 6,\n \"newUser\": false\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/alt", + "host": ["{{baseUrl}}"], + "path": ["alt"] + }, + "description": "Send OTP to mobile number for existing user login" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.Data && jsonData.data.Data.ReferenceNumber) {", + " pm.environment.set('referenceNumber', jsonData.data.Data.ReferenceNumber);", + " console.log('Login OTP Reference: ' + jsonData.data.Data.ReferenceNumber);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Step 2: Verify OTP & Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"VerifyOTP\",\n \"payload\": {\n \"referenceNumber\": \"{{referenceNumber}}\",\n \"otpCode\": \"123456\",\n \"newUser\": false,\n \"identifier\": \"{{mobileNumber}}\",\n \"deviceName\": \"iPhone 14 Pro\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/alt", + "host": ["{{baseUrl}}"], + "path": ["alt"] + }, + "description": "Verify OTP and complete login - returns JWT tokens" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData.data && jsonData.data.AccessToken) {", + " pm.environment.set('accessToken', jsonData.data.AccessToken);", + " pm.environment.set('refreshToken', jsonData.data.RefreshToken);", + " console.log('Login successful - Tokens saved');", + "}" + ], + "type": "text/javascript" + } + } + ] + } + ] + }, + { + "name": "Authenticated Mobile Operations", + "item": [ + { + "name": "Get User Details", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"getUserDetails\",\n \"payload\": {}\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Get authenticated user details" + }, + "response": [] + }, + { + "name": "Get User Sessions", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"getUserSessions\",\n \"payload\": {}\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Get all active sessions for the user" + }, + "response": [] + }, + { + "name": "Change Password", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"ChangeUserPassword\",\n \"payload\": {\n \"currentPassword\": \"SecurePass123!\",\n \"newPassword\": \"NewSecurePass456!\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Change user password" + }, + "response": [] + }, + { + "name": "Logout", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"LogoutUser\",\n \"payload\": {}\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Logout user and invalidate current session" + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.environment.unset('accessToken');", + "pm.environment.unset('refreshToken');", + "console.log('Logged out - Tokens cleared');" + ], + "type": "text/javascript" + } + } + ] + } + ] + }, + { + "name": "Two-Factor Authentication (Mobile)", + "item": [ + { + "name": "Initiate 2FA Setup", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"initiateTwoFASetup\",\n \"payload\": {}\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Get QR code and secret for Google Authenticator setup" + }, + "response": [] + }, + { + "name": "Complete 2FA Setup", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"completeTwoFASetup\",\n \"payload\": {\n \"verificationCode\": \"123456\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Verify and complete 2FA setup" + }, + "response": [] + }, + { + "name": "Verify 2FA Code", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"verifyTwoFA\",\n \"payload\": {\n \"code\": \"123456\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Verify 2FA code during login" + }, + "response": [] + }, + { + "name": "Get 2FA Status", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"getTwoFAStatus\",\n \"payload\": {}\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Check if 2FA is enabled for user" + }, + "response": [] + }, + { + "name": "Disable 2FA", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"disableTwoFA\",\n \"payload\": {\n \"password\": \"SecurePass123!\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Disable 2FA for user account" + }, + "response": [] + } + ] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + } +} diff --git a/AuthHex_Postman_Collection.json b/AuthHex_Postman_Collection.json new file mode 100644 index 0000000..fa4eeb9 --- /dev/null +++ b/AuthHex_Postman_Collection.json @@ -0,0 +1,600 @@ +{ + "info": { + "name": "AuthHex API Collection", + "description": "Complete API collection for AuthHex authentication system", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_postman_id": "auth-hex-api-collection", + "version": "1.0.0" + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://localhost:7001/api", + "description": "Base URL for AuthHex API" + }, + { + "key": "accessToken", + "value": "", + "description": "JWT Access Token - will be set automatically after login" + }, + { + "key": "refreshToken", + "value": "", + "description": "JWT Refresh Token - will be set automatically after login" + } + ], + "item": [ + { + "name": "Health & Status", + "item": [ + { + "name": "API Status Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/status", + "host": ["{{baseUrl}}"], + "path": ["status"] + }, + "description": "Check if API is running and get current timestamp" + }, + "response": [] + } + ] + }, + { + "name": "Authentication", + "item": [ + { + "name": "Register User", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"registerUser\",\n \"payload\": {\n \"fullName\": \"John Doe\",\n \"userName\": \"johndoe\",\n \"email\": \"john.doe@example.com\",\n \"mobileNumber\": \"+1234567890\",\n \"nic\": \"123456789V\",\n \"password\": \"Password123!\",\n \"roleId\": \"00000000-0000-0000-0000-000000000001\",\n \"userTypeId\": \"00000000-0000-0000-0000-000000000001\",\n \"chkUser\": true\n },\n \"reference\": \"REG001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/registerUser", + "host": ["{{baseUrl}}"], + "path": ["registerUser"] + }, + "description": "Register a new user account" + }, + "response": [] + }, + { + "name": "Login User", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"loginUser\",\n \"payload\": {\n \"identifier\": \"john.doe@example.com\",\n \"password\": \"Password123!\",\n \"userTypeId\": \"00000000-0000-0000-0000-000000000001\",\n \"deviceName\": \"Postman Client\"\n },\n \"reference\": \"LOG001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/loginUser", + "host": ["{{baseUrl}}"], + "path": ["loginUser"] + }, + "description": "Authenticate user and get access token" + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const response = pm.response.json();", + " if (response.data && response.data.AccessToken) {", + " pm.collectionVariables.set('accessToken', response.data.AccessToken);", + " pm.collectionVariables.set('refreshToken', response.data.RefreshToken);", + " console.log('Access token saved to collection variables');", + " }", + "}" + ] + } + } + ], + "response": [] + } + ] + }, + { + "name": "User Management", + "item": [ + { + "name": "Get User Details", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"getUserDetails\",\n \"payload\": {},\n \"reference\": \"GETUSR001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Get current user profile details" + }, + "response": [] + }, + { + "name": "Get User Sessions", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"getUserSessions\",\n \"payload\": {},\n \"reference\": \"GETSESS001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Get all active sessions for current user" + }, + "response": [] + }, + { + "name": "Change User Status", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"ChangeUserStatus\",\n \"payload\": {\n \"userId\": \"00000000-0000-0000-0000-000000000001\",\n \"isActive\": true\n },\n \"reference\": \"CHGSTAT001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Activate or deactivate a user account" + }, + "response": [] + }, + { + "name": "Lock User Account", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"LockUserAccount\",\n \"payload\": {\n \"userId\": \"00000000-0000-0000-0000-000000000001\",\n \"isLocked\": true\n },\n \"reference\": \"LOCKUSR001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Lock or unlock a user account" + }, + "response": [] + }, + { + "name": "Change User Password", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"ChangeUserPassword\",\n \"payload\": {\n \"currentPassword\": \"OldPassword123!\",\n \"newPassword\": \"NewPassword123!\"\n },\n \"reference\": \"CHGPASS001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Change current user password" + }, + "response": [] + }, + { + "name": "Logout User", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"LogoutUser\",\n \"payload\": {},\n \"reference\": \"LOGOUT001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Logout current user and invalidate session" + }, + "response": [] + } + ] + }, + { + "name": "Two-Factor Authentication (2FA)", + "item": [ + { + "name": "Get 2FA Status", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"getTwoFAStatus\",\n \"payload\": {},\n \"reference\": \"2FASTAT001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Check if 2FA is enabled for current user" + }, + "response": [] + }, + { + "name": "Initiate 2FA Setup", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"initiateTwoFASetup\",\n \"payload\": {},\n \"reference\": \"2FAINIT001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Generate QR code and secret for 2FA setup" + }, + "response": [] + }, + { + "name": "Complete 2FA Setup", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"completeTwoFASetup\",\n \"payload\": {\n \"verificationCode\": \"123456\"\n },\n \"reference\": \"2FACOMP001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Complete 2FA setup by verifying the code" + }, + "response": [] + }, + { + "name": "Verify 2FA Code", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"verifyTwoFA\",\n \"payload\": {\n \"verificationCode\": \"123456\"\n },\n \"reference\": \"2FAVRF001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Verify 2FA code during login or operations" + }, + "response": [] + }, + { + "name": "Disable 2FA", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"disableTwoFA\",\n \"payload\": {\n \"verificationCode\": \"123456\"\n },\n \"reference\": \"2FADIS001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/user", + "host": ["{{baseUrl}}"], + "path": ["user"] + }, + "description": "Disable 2FA for current user" + }, + "response": [] + } + ] + }, + { + "name": "Password Recovery", + "item": [ + { + "name": "Forgot Password (OTP)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"forgotPassword\",\n \"payload\": {\n \"identifier\": \"john.doe@example.com\",\n \"useResetLink\": false,\n \"numberOfDigits\": 6\n },\n \"reference\": \"FORGPWD001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/forgotPassword", + "host": ["{{baseUrl}}"], + "path": ["forgotPassword"] + }, + "description": "Send OTP for password recovery via SMS/Email" + }, + "response": [] + }, + { + "name": "Forgot Password (Reset Link)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"forgotPassword\",\n \"payload\": {\n \"identifier\": \"john.doe@example.com\",\n \"useResetLink\": true\n },\n \"reference\": \"FORGPWD002\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/forgotPassword", + "host": ["{{baseUrl}}"], + "path": ["forgotPassword"] + }, + "description": "Send password reset link via Email" + }, + "response": [] + }, + { + "name": "Verify OTP", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"verifyOTP\",\n \"payload\": {\n \"referenceNumber\": \"REF123456789\",\n \"otpCode\": \"123456\"\n },\n \"reference\": \"VEROTP001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/recovery", + "host": ["{{baseUrl}}"], + "path": ["recovery"] + }, + "description": "Verify OTP code for password recovery" + }, + "response": [] + }, + { + "name": "Reset Password with Token", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"resetPasswordWithToken\",\n \"payload\": {\n \"resetToken\": \"your-reset-token-here\",\n \"newPassword\": \"NewPassword123!\"\n },\n \"reference\": \"RESETPWD001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/recovery", + "host": ["{{baseUrl}}"], + "path": ["recovery"] + }, + "description": "Reset password using reset token from email link" + }, + "response": [] + }, + { + "name": "Reset Password with OTP", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"resetPassword\",\n \"payload\": {\n \"referenceNumber\": \"REF123456789\",\n \"newPassword\": \"NewPassword123!\"\n },\n \"reference\": \"RESETPWD002\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/recovery", + "host": ["{{baseUrl}}"], + "path": ["recovery"] + }, + "description": "Reset password after OTP verification" + }, + "response": [] + } + ] + }, + { + "name": "Utility Functions", + "item": [ + { + "name": "Check Availability", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"isAvailable\",\n \"payload\": {\n \"resetToken\": \"john.doe@example.com\"\n },\n \"reference\": \"CHKAVAIL001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/isAvailable", + "host": ["{{baseUrl}}"], + "path": ["isAvailable"] + }, + "description": "Check if email/username/mobile is available for registration" + }, + "response": [] + } + ] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ] +} \ No newline at end of file diff --git a/Controllers/APIController.cs b/Controllers/APIController.cs new file mode 100644 index 0000000..1e5bfb0 --- /dev/null +++ b/Controllers/APIController.cs @@ -0,0 +1,79 @@ +using AuthHex.Models.IOs; +using AuthHex.Services.AltOptionManager; +using AuthHex.Services.FunctionHandler; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; + +namespace AuthHex.Controllers +{ + [ApiController] + [Route("api")] + [EnableCors("AllowAll")] + public class APIController(UserManager userManager, RecoveryManager recoveryManager, AltOptionManager altOptionManager) : ControllerBase + { + private readonly UserManager _userManager = userManager; + private readonly RecoveryManager _recoveryManager = recoveryManager; + private readonly AltOptionManager _altOptionManager = altOptionManager; + + [HttpGet("status")] + [AllowAnonymous] + public IActionResult GetStatus() + { + return Ok(new { status = "API is running", timestamp = System.DateTime.UtcNow }); + } + + [HttpPost("user")] + [AllowAnonymous] + public async Task UserExecute([FromBody] ApiRequest request) + { + var response = await _userManager.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("recovery")] + [AllowAnonymous] + public async Task RecoveryExecute([FromBody] ApiRequest request) + { + var response = await _recoveryManager.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("registerUser")] + [AllowAnonymous] + public async Task RegisterUser([FromBody] ApiRequest request) + { + request.FunctionName = "registerUser"; + var response = await _userManager.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("loginUser")] + [AllowAnonymous] + public async Task LoginUser([FromBody] ApiRequest request) + { + request.FunctionName = "loginUser"; + var response = await _userManager.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("forgotPassword")] + [AllowAnonymous] + public async Task ForgotPassword([FromBody] ApiRequest request) + { + request.FunctionName = "forgotPassword"; + var response = await _recoveryManager.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("alt")] + [AllowAnonymous] + public async Task IsAvailable([FromBody] ApiRequest request) + { + + var response = await _altOptionManager.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + } +} diff --git a/DTOs/OtpResponse.cs b/DTOs/OtpResponse.cs new file mode 100644 index 0000000..cd38c35 --- /dev/null +++ b/DTOs/OtpResponse.cs @@ -0,0 +1,10 @@ +namespace AuthHex.DTOs +{ + public class OtpResponse + { + public int? StatusCode { get; set; } + public string? Message { get; set; } + public string? ApiResponse { get; set; } + public string? Error { get; set; } + } +} diff --git a/DTOs/SmsRequest.cs b/DTOs/SmsRequest.cs new file mode 100644 index 0000000..af392b9 --- /dev/null +++ b/DTOs/SmsRequest.cs @@ -0,0 +1,9 @@ +namespace AuthHex.DTOs +{ + public class SmsRequest + { + public string? api_key { get; set; } + public string? phone_number { get; set; } + public string? msg_text { get; set; } + } +} diff --git a/DTOs/Tute/UpdateTuteItemRequest.cs b/DTOs/Tute/UpdateTuteItemRequest.cs new file mode 100644 index 0000000..c3fee65 --- /dev/null +++ b/DTOs/Tute/UpdateTuteItemRequest.cs @@ -0,0 +1,9 @@ +namespace AuthHex.DTOs.Tute +{ + public class UpdateTuteItemRequest + { + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? Notes { get; set; } + } +} diff --git a/Exceptions/CustomExceptions.cs b/Exceptions/CustomExceptions.cs new file mode 100644 index 0000000..0341874 --- /dev/null +++ b/Exceptions/CustomExceptions.cs @@ -0,0 +1,22 @@ +namespace Core_Archi.Exceptions +{ + public class NotFoundException : Exception + { + public NotFoundException(string message) : base(message) { } + } + + public class BadRequestException : Exception + { + public BadRequestException(string message) : base(message) { } + } + + public class UnauthorizedException : Exception + { + public UnauthorizedException(string message) : base(message) { } + } + + public class ForbiddenException : Exception + { + public ForbiddenException(string message) : base(message) { } + } +} diff --git a/GenerateRsaKeys.cs b/GenerateRsaKeys.cs new file mode 100644 index 0000000..6117bf6 --- /dev/null +++ b/GenerateRsaKeys.cs @@ -0,0 +1,32 @@ +using System.Security.Cryptography; + +namespace AuthHex; + +/// +/// Utility class to generate RSA key pairs for JWT authentication. +/// Run this once to generate keys and copy them to appsettings.json +/// +public class RsaKeyGenerator +{ + public static void GenerateAndPrintKeys() + { + using var rsa = RSA.Create(2048); + + var privateKey = rsa.ToXmlString(true); // includes private key + var publicKey = rsa.ToXmlString(false); // public key only + + Console.WriteLine("=== RSA Private Key (Keep this SECRET!) ==="); + Console.WriteLine(privateKey); + Console.WriteLine(); + + Console.WriteLine("=== RSA Public Key ==="); + Console.WriteLine(publicKey); + Console.WriteLine(); + + Console.WriteLine("Copy the Private Key to appsettings.json -> Jwt:RsaPrivateKey"); + Console.WriteLine("Copy the Public Key to appsettings.json -> Jwt:RsaPublicKey"); + Console.WriteLine(); + Console.WriteLine("IMPORTANT: Never commit the private key to source control!"); + Console.WriteLine("Consider using Azure Key Vault or environment variables for production."); + } +} diff --git a/Helpers/DataExtractor.cs b/Helpers/DataExtractor.cs new file mode 100644 index 0000000..c8b2ba3 --- /dev/null +++ b/Helpers/DataExtractor.cs @@ -0,0 +1,195 @@ +using System.Text.Json; +using Core_Archi.Exceptions; + +namespace Core_Archi.Helpers +{ + public class DataExtractor + { + public static string GetRequiredString( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.ValueKind != JsonValueKind.String) + return ""; + //throw new BadRequestException($"Field '{key}' must be a string."); + + var value = element.GetString(); + + if (string.IsNullOrWhiteSpace(value)) + throw new BadRequestException($"Field '{key}' cannot be empty."); + + return value; + } + + // ===================== INT ===================== + + public static int GetRequiredInt( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.TryGetInt32(out var value)) + return value; + + throw new BadRequestException($"Field '{key}' must be a valid integer."); + } + + // ===================== FLOAT ===================== + + public static float GetRequiredFloat( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.TryGetSingle(out var value)) + return value; + + // Allow numeric strings: "12.5" + if (element.ValueKind == JsonValueKind.String && + float.TryParse(element.GetString(), out value)) + { + return value; + } + + throw new BadRequestException($"Field '{key}' must be a valid float."); + } + + // ===================== DECIMAL ===================== + public static decimal GetRequiredDecimal( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new ArgumentException($"Missing required field '{key}'."); + if (element.TryGetDecimal(out var value)) + return value; + // Allow numeric strings: "12.5" + if (element.ValueKind == JsonValueKind.String && + decimal.TryParse(element.GetString(), out value)) + { + return value; + } + throw new ArgumentException($"Field '{key}' must be a valid decimal."); + } + + // ===================== BOOLEAN ===================== + + public static bool GetRequiredBool( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.ValueKind == JsonValueKind.True) return true; + if (element.ValueKind == JsonValueKind.False) return false; + + // Allow string values: "true" / "false" + if (element.ValueKind == JsonValueKind.String && + bool.TryParse(element.GetString(), out var value)) + { + return value; + } + + throw new BadRequestException($"Field '{key}' must be a valid boolean."); + } + + // ===================== GUID ===================== + + public static Guid GetRequiredGuid( + Dictionary data, + string key) + { + var value = GetRequiredString(data, key); + + if (!Guid.TryParse(value, out var guid)) + throw new BadRequestException($"Field '{key}' must be a valid GUID."); + + return guid; + } + + // ===================== DATETIME ===================== + + public static DateTime GetRequiredDateTime( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.ValueKind == JsonValueKind.Null) + throw new BadRequestException($"Field '{key}' cannot be null."); + + // ISO 8601 string + if (element.ValueKind == JsonValueKind.String && + DateTime.TryParse(element.GetString(), out var date)) + { + return date; + } + + // Native JSON DateTime + if (element.TryGetDateTime(out date)) + { + return date; + } + + throw new BadRequestException($"Field '{key}' must be a valid DateTime."); + } + + // ===================== DATEONLY ===================== + + public static DateOnly GetRequiredDateOnly( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.ValueKind == JsonValueKind.Null) + throw new BadRequestException($"Field '{key}' cannot be null."); + + DateTime date; + + // ISO 8601 string (e.g., "2025-02-25") + if (element.ValueKind == JsonValueKind.String && + DateTime.TryParse(element.GetString(), out date)) + { + return DateOnly.FromDateTime(date); + } + + + if (element.TryGetDateTime(out date)) + { + return DateOnly.FromDateTime(date); + } + + throw new BadRequestException($"Field '{key}' must be a valid date."); + } + + // ===================== USER ID FROM HttpContext ===================== + public static Guid GetUserIdFromContext(HttpContext httpContext) + { + + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + + var userIdFromContext = httpContext.Items["UserId"]?.ToString() + ?? httpContext.User?.FindFirst("UserId")?.Value; + + if (string.IsNullOrWhiteSpace(userIdFromContext) || !Guid.TryParse(userIdFromContext, out var userId)) + { + throw new UnauthorizedAccessException("User is not authenticated. Please login and send cookie."); + } + + return userId; + } + + + } +} diff --git a/Helpers/DeserializePayload.cs b/Helpers/DeserializePayload.cs new file mode 100644 index 0000000..ea10c2d --- /dev/null +++ b/Helpers/DeserializePayload.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +namespace Core_Archi.Helpers +{ + public static class DeserializePayload + { + public static Dictionary Deserialize(object payload) + { + ArgumentNullException.ThrowIfNull(payload); + var json = JsonSerializer.Serialize(payload); + return JsonSerializer.Deserialize>(json) + ?? throw new InvalidOperationException("Failed to deserialize payload."); + } + } +} diff --git a/Helpers/UserHelper.cs b/Helpers/UserHelper.cs new file mode 100644 index 0000000..b7b7747 --- /dev/null +++ b/Helpers/UserHelper.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Http; + +namespace Core_Archi.Helpers; + +public class UserHelper +{ + public string? ExtractBrowser(string? userAgent) + { + if (string.IsNullOrEmpty(userAgent)) return null; + + if (userAgent.Contains("Chrome")) return "Chrome"; + if (userAgent.Contains("Firefox")) return "Firefox"; + if (userAgent.Contains("Safari") && !userAgent.Contains("Chrome")) return "Safari"; + if (userAgent.Contains("Edge")) return "Edge"; + if (userAgent.Contains("Opera")) return "Opera"; + + return "Other"; + } + + public string? ExtractOS(string? userAgent) + { + if (string.IsNullOrEmpty(userAgent)) return null; + + if (userAgent.Contains("Windows")) return "Windows"; + if (userAgent.Contains("Mac OS")) return "macOS"; + if (userAgent.Contains("Linux")) return "Linux"; + if (userAgent.Contains("Android")) return "Android"; + if (userAgent.Contains("iOS") || userAgent.Contains("iPhone") || userAgent.Contains("iPad")) return "iOS"; + + return "Other"; + } + + public Guid GetUserIdFromClaims(HttpContext httpContext) + { + var userIdClaim = httpContext.User.FindFirst("userId") + ?? httpContext.User.FindFirst("UserId") + ?? httpContext.User.FindFirst("sub"); + + if (userIdClaim == null || !Guid.TryParse(userIdClaim.Value, out var userId)) + throw new ArgumentException("User ID not found in token"); + + return userId; + } +} \ No newline at end of file diff --git a/Infra/UoW/EFUnitOfWork.cs b/Infra/UoW/EFUnitOfWork.cs new file mode 100644 index 0000000..772876a --- /dev/null +++ b/Infra/UoW/EFUnitOfWork.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Storage; + +namespace AuthHex.Infra.UoW +{ + public class EFUnitOfWork : IUnitOfWork + { + private readonly AppDBContext _dbContext; + private IDbContextTransaction? _transaction; + + public EFUnitOfWork(AppDBContext dbContext) + { + _dbContext = dbContext; + } + + public async Task BeginAsync(CancellationToken ct = default) + { + if (_transaction == null) + _transaction = await _dbContext.Database.BeginTransactionAsync(ct); + } + + public async Task SaveChangesAsync(CancellationToken ct = default) + { + await _dbContext.SaveChangesAsync(ct); + } + + public async Task CommitAsync(CancellationToken ct = default) + { + await _dbContext.SaveChangesAsync(ct); + + if (_transaction != null) + { + await _transaction.CommitAsync(ct); + await _transaction.DisposeAsync(); + _transaction = null; + } + } + + public async Task RollbackAsync(CancellationToken ct = default) + { + if (_transaction != null) + { + await _transaction.RollbackAsync(ct); + await _transaction.DisposeAsync(); + _transaction = null; + } + } + + } +} diff --git a/Infra/UoW/IUnitOfWork.cs b/Infra/UoW/IUnitOfWork.cs new file mode 100644 index 0000000..db1a138 --- /dev/null +++ b/Infra/UoW/IUnitOfWork.cs @@ -0,0 +1,10 @@ +namespace AuthHex.Infra.UoW +{ + public interface IUnitOfWork + { + Task BeginAsync(CancellationToken ct = default); + Task CommitAsync(CancellationToken ct = default); + Task RollbackAsync(CancellationToken ct = default); + Task SaveChangesAsync(CancellationToken ct = default); + } +} diff --git a/Interfaces/IAltOptionManagerRepository.cs b/Interfaces/IAltOptionManagerRepository.cs new file mode 100644 index 0000000..05469dc --- /dev/null +++ b/Interfaces/IAltOptionManagerRepository.cs @@ -0,0 +1,10 @@ +using AuthHex.Models; + +namespace AuthHex.Interfaces +{ + public interface IAltOptionManagerRepository + { + + + } +} diff --git a/Interfaces/IRecoveryManagerRepository.cs.cs b/Interfaces/IRecoveryManagerRepository.cs.cs new file mode 100644 index 0000000..5b91539 --- /dev/null +++ b/Interfaces/IRecoveryManagerRepository.cs.cs @@ -0,0 +1,13 @@ +using AuthHex.Models; + +namespace AuthHex.Interfaces +{ + public interface IRecoveryManagerRepository + { + Task AddRecoveryAsync(Recovery recovery, CancellationToken ct = default); + Task GetRecoveryByTokenHashAsync(string tokenHash, CancellationToken ct = default); + Task GetRecoveryByReferenceNumAsync(string referenceNum, CancellationToken ct = default); + Task UpdateRecoveryAsync(Recovery recovery, CancellationToken ct = default); + Task> GetPendingRecoveriesByUserIdAsync(Guid userId, CancellationToken ct = default); + } +} diff --git a/Interfaces/IUserManageRepository.cs b/Interfaces/IUserManageRepository.cs new file mode 100644 index 0000000..f5f2dde --- /dev/null +++ b/Interfaces/IUserManageRepository.cs @@ -0,0 +1,19 @@ +using AuthHex.Models; + +namespace AuthHex.Interfaces +{ + public interface IUserManageRepository + { + Task AddUserAsync(User user, CancellationToken ct = default); + Task AddTokenAsync(Token token, CancellationToken ct = default); + Task> GetUserByIdentifiers(string? email, string? mobileNumber, string? nic, string? username, string? Optional1); + Task GetUserByIdentifierAndType(string identifier, Guid? userTypeId); + Task GetUserByIdAsync(Guid userId, CancellationToken ct = default); + Task UpdateUserAsync(User user, CancellationToken ct = default); + Task> GetUserSessionsAsync(Guid userId, CancellationToken ct = default); + Task GetActiveSessionByRefreshTokenAsync(string refreshToken, CancellationToken ct = default); + Task InvalidateUserSessionsAsync(Guid userId, CancellationToken ct = default); + + //Task GetExUser (string identifier); + } +} diff --git a/Models/AuthEventLog.cs b/Models/AuthEventLog.cs new file mode 100644 index 0000000..0ff4a30 --- /dev/null +++ b/Models/AuthEventLog.cs @@ -0,0 +1,13 @@ +namespace AuthHex.Models; +public class AuthEventLog +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid? UserId { get; set; } + + public string? EventType { get; set; } // LOGIN_SUCCESS, LOGIN_FAILED, LOCKED + public string? IPAddress { get; set; } + public string? UserAgent { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Models/IOs/ApiRequest.cs b/Models/IOs/ApiRequest.cs new file mode 100644 index 0000000..3e3eb27 --- /dev/null +++ b/Models/IOs/ApiRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace AuthHex.Models.IOs +{ + [NotMapped] + public class ApiRequest + { + [Required] + public string FunctionName { get; set; } = string.Empty; + + public object Payload { get; set; } = new(); + + [Required(AllowEmptyStrings = true)] + public string? Reference { get; set; } + } +} diff --git a/Models/IOs/ApiResponse.cs b/Models/IOs/ApiResponse.cs new file mode 100644 index 0000000..4cb3f48 --- /dev/null +++ b/Models/IOs/ApiResponse.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; + +namespace AuthHex.Models.IOs +{ + [NotMapped] + public class ApiResponse + { + public int StatusCode { get; set; } = 200; + public bool Success { get; set; } = true; + public string? Message { get; set; } + public object? Data { get; set; } + } +} diff --git a/Models/Recovery.cs b/Models/Recovery.cs new file mode 100644 index 0000000..6c736c4 --- /dev/null +++ b/Models/Recovery.cs @@ -0,0 +1,32 @@ +namespace AuthHex.Models +{ + public class Recovery + { + public Guid Id { get; set; } + + public Guid? UserId { get; set; } + public User? User { get; set; } + + public string? RecoveryReferenceNum { get; set; } + + // For OTP-based recovery + public string? OTP { get; set; } + public string? OTPReferenceNum { get; set; } + + + // For Token-based reset (SECURE - hashed) + public string? ResetTokenHash { get; set; } + public string? ResetToken { get; set; } // Plain token (only for email, not saved) + + + public string Status { get; set; } = "Pending"; // Pending, Used, Expired + public bool IsUsed { get; set; } = false; // Prevent token reuse + + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime ExpirationTime { get; set; } = DateTime.UtcNow.AddMinutes(15); + + // Recovery type: OTP or ResetLink + public string RecoveryType { get; set; } = "OTP"; // OTP, ResetLink + } +} diff --git a/Models/Role.cs b/Models/Role.cs new file mode 100644 index 0000000..e834360 --- /dev/null +++ b/Models/Role.cs @@ -0,0 +1,15 @@ +namespace AuthHex.Models +{ + public class Role + { + public Guid RoleId { get; set; } + + public string Code { get; set; } = string.Empty; // ADMIN, USER, STAFF + public string?Name { get; set; } = string.Empty; + + public bool? IsSystemRole { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + } +} diff --git a/Models/SysConfig.cs b/Models/SysConfig.cs new file mode 100644 index 0000000..24f0615 --- /dev/null +++ b/Models/SysConfig.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace AuthHex.Models +{ + public class SysConfig + { + [Key] + public int SysConfigId { get; set; } + public int TenantId { get; set; } + public string Configuration { get; set; } + public string Value { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/Token.cs b/Models/Token.cs new file mode 100644 index 0000000..5ef539c --- /dev/null +++ b/Models/Token.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace AuthHex.Models +{ + public class Token + { + + public Guid TokenId { get; set; } + + public Guid UserId { get; set; } + public User? User { get; set; } + + public string? TokenHash { get; set; } + + public string? Type { get; set; } // email verify, password reset, etc. + + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UsedAt { get; set; } + + } +} diff --git a/Models/User.cs b/Models/User.cs new file mode 100644 index 0000000..e3e868f --- /dev/null +++ b/Models/User.cs @@ -0,0 +1,44 @@ +using System.Data; + +namespace AuthHex.Models +{ + public class User + { + public Guid Id { get; set; } = Guid.NewGuid(); + public string? FullName { get; set; } + public string? UserName { get; set; } + public string? Nic { get; set; } + public string? Address { get; set; } + public string? Optional1 { get; set; } //in gym case use in secondary contact number + public bool? Optional1Verified { get; set; } = false; + public string? Optional2 { get; set; } + public string? PasswordHash { get; set; } + + public string? Email { get; set; } + public bool? EmailVerified { get; set; } = false; + + public string? MobileNumber { get; set; } + public bool? MobileNumberVerified { get; set; } = false; + + public bool? IsActive { get; set; } = true; + public bool? IsLocked { get; set; } = false; + public DateTime? DeletedAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + + //2FA + public bool IsMfaEnabled { get; set; } + public UserMfa? UserMfa { get; set; } + + //User Role and Type + public Guid RoleId { get; set; } + public Role? Role { get; set; } + + public Guid UserTypeId { get; set; } + public UserType? UserType { get; set; } + + + public ICollection? Sessions { get; set; } + public ICollection? Providers { get; set; } + } +} diff --git a/Models/UserMfa.cs b/Models/UserMfa.cs new file mode 100644 index 0000000..1a71a98 --- /dev/null +++ b/Models/UserMfa.cs @@ -0,0 +1,19 @@ +namespace AuthHex.Models; + +public class UserMfa +{ + public Guid UserMfaId { get; set; } + + public Guid UserId { get; set; } + public User User { get; set; } = null!; + + public string? SecretKey { get; set; } // encrypted + public bool? IsEnabled { get; set; } + public bool? IsVerified { get; set; } = false; + public string? BackupCodes { get; set; } // JSON array of backup codes + public DateTime? LastUsedAt { get; set; } + public DateTime? VerifiedAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/Models/UserProvider.cs b/Models/UserProvider.cs new file mode 100644 index 0000000..97ad2b4 --- /dev/null +++ b/Models/UserProvider.cs @@ -0,0 +1,13 @@ +namespace AuthHex.Models; +public class UserProvider +{ + public Guid ProviderId { get; set; } + + public Guid UserId { get; set; } + public User User { get; set; } = null!; + + public string? Provider { get; set; } // Google, facebook, etc. + public string? ProviderUserId { get; set; } + + public DateTime LinkedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Models/UserSession.cs b/Models/UserSession.cs new file mode 100644 index 0000000..21975e4 --- /dev/null +++ b/Models/UserSession.cs @@ -0,0 +1,25 @@ +namespace AuthHex.Models +{ + public class UserSession + { + + public Guid SessionId { get; set; } + + public Guid UserId { get; set; } + + public User User { get; set; } = null!; + + public string RefreshTokenHash { get; set; } = string.Empty; + + public string? DeviceName { get; set; } + public string? OS { get; set; } + public string? Browser { get; set; } + public string? IPAddress { get; set; } + + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? RevokedAt { get; set; } + + public bool IsActive => RevokedAt == null && ExpiresAt > DateTime.UtcNow; + } +} diff --git a/Models/UserType.cs b/Models/UserType.cs new file mode 100644 index 0000000..f7879a7 --- /dev/null +++ b/Models/UserType.cs @@ -0,0 +1,13 @@ +namespace AuthHex.Models +{ + public class UserType + { + public Guid UserTypeId { get; set; } + + public string Code { get; set; } = string.Empty; // INDIVIDUAL, MERCHANT, AGENT + public string? Description { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..73c076a --- /dev/null +++ b/Program.cs @@ -0,0 +1,126 @@ +using Core_Archi.Helpers; +using AuthHex; +using AuthHex.Infra.UoW; +using AuthHex.Interfaces; +using AuthHex.Repos; +using AuthHex.Services.UserManager; +using AuthHex.Services.RecoveryManager; +using AuthHex.Utility; +using AuthSystem.API.Utils; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContext(options => +{ + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); + options.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 21))); +}); + +// Configure RSA Key Provider +var privateKeyXml = builder.Configuration["Jwt:RsaPrivateKey"]!; +var rsaKeyProvider = new RsaKeyProvider(privateKeyXml); +builder.Services.AddSingleton(rsaKeyProvider); + +// Add JWT Authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + IssuerSigningKey = rsaKeyProvider.GetPublicKey(), + ClockSkew = TimeSpan.Zero + }; +}); + +builder.Services.AddAuthorization(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var configuredOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); +var allowedOrigins = configuredOrigins + .Where(origin => !string.IsNullOrWhiteSpace(origin) && origin != "*") + .ToList(); + +var frontendUrl = builder.Configuration["AppSettings:FrontendUrl"]; +if (!string.IsNullOrWhiteSpace(frontendUrl) && + !allowedOrigins.Contains(frontendUrl, StringComparer.OrdinalIgnoreCase)) +{ + allowedOrigins.Add(frontendUrl); +} + +// Add CORS policy +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + if (allowedOrigins.Count > 0) + { + policy.WithOrigins(allowedOrigins.ToArray()) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + return; + } + + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + + +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} + +app.UseRouting(); + +app.UseCors("AllowAll"); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..04e36a0 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:47780", + "sslPort": 44395 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5044", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7152;http://localhost:5044", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/Repos/AltOptionManagerRepository.cs b/Repos/AltOptionManagerRepository.cs new file mode 100644 index 0000000..cc81d24 --- /dev/null +++ b/Repos/AltOptionManagerRepository.cs @@ -0,0 +1,13 @@ +using AuthHex.Models; +using AuthHex.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace AuthHex.Repos +{ + public class AltOptionManagerRepository : IAltOptionManagerRepository + { + + + + } +} diff --git a/Repos/RecoveryManagerRepository.cs b/Repos/RecoveryManagerRepository.cs new file mode 100644 index 0000000..987ff2a --- /dev/null +++ b/Repos/RecoveryManagerRepository.cs @@ -0,0 +1,50 @@ +using AuthHex.Models; +using AuthHex.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace AuthHex.Repos +{ + public class RecoveryManagerRepository : IRecoveryManagerRepository + { + private readonly AppDBContext _dbContext; + + public RecoveryManagerRepository(AppDBContext dbContext) + { + _dbContext = dbContext; + } + + public async Task AddRecoveryAsync(Recovery recovery, CancellationToken ct = default) + { + _dbContext.Recovery.Add(recovery); + await _dbContext.SaveChangesAsync(ct); + return recovery; + } + + public async Task GetRecoveryByTokenHashAsync(string tokenHash, CancellationToken ct = default) + { + return await _dbContext.Recovery + .Include(r => r.User) + .FirstOrDefaultAsync(r => r.ResetTokenHash == tokenHash && !r.IsUsed, ct); + } + + public async Task GetRecoveryByReferenceNumAsync(string referenceNum, CancellationToken ct = default) + { + return await _dbContext.Recovery + .Include(r => r.User) + .FirstOrDefaultAsync(r => r.RecoveryReferenceNum == referenceNum, ct); + } + + public async Task UpdateRecoveryAsync(Recovery recovery, CancellationToken ct = default) + { + _dbContext.Recovery.Update(recovery); + await _dbContext.SaveChangesAsync(ct); + } + + public async Task> GetPendingRecoveriesByUserIdAsync(Guid userId, CancellationToken ct = default) + { + return await _dbContext.Recovery + .Where(r => r.UserId == userId && !r.IsUsed && r.Status == "Pending") + .ToListAsync(ct); + } + } +} diff --git a/Repos/UserManageRepository.cs b/Repos/UserManageRepository.cs new file mode 100644 index 0000000..b30b908 --- /dev/null +++ b/Repos/UserManageRepository.cs @@ -0,0 +1,183 @@ +using AuthHex.Interfaces; +using AuthHex.Models; +using AuthHex.Utility.passwordHasher; +using Microsoft.EntityFrameworkCore; +using System.Linq; + +namespace AuthHex.Repos +{ + public class UserManageRepository : IUserManageRepository + { + private readonly AppDBContext _dbContext; + + public UserManageRepository(AppDBContext dbContext) + { + _dbContext = dbContext; + } + public Task AddUserAsync(User user, CancellationToken ct = default) + { + _dbContext.Users.Add(user); + return Task.FromResult(user); + } + public Task AddTokenAsync(Token token, CancellationToken ct = default) + { + _dbContext.Token.Add(token); + return Task.FromResult(token); + } + + + + public async Task> GetUserByIdentifiers(string? email, string? mobileNumber, string? nic, string? username, string? Optional1) + { + var query = _dbContext.Users.AsQueryable(); + + query = query.Where(u => + (!string.IsNullOrEmpty(email) && u.Email == email) || + (!string.IsNullOrEmpty(mobileNumber) && u.MobileNumber == mobileNumber) || + (!string.IsNullOrEmpty(nic) && u.Nic == nic) || + (!string.IsNullOrEmpty(username) && u.UserName == username) || + (!string.IsNullOrEmpty(Optional1) && u.Optional1 == Optional1) + ); + + return await query.ToListAsync(); + } + + + public async Task GetUserByIdentifierAndType(string identifier, Guid? userTypeId) + { + + User? user = await _dbContext.Users + .FirstOrDefaultAsync(u => + u.Email == identifier || + u.MobileNumber == identifier || + u.Nic == identifier + ); + + //var query = _dbContext.Users.AsQueryable(); + + //query = query.Where(u => + // u.Email == identifier || + // u.MobileNumber == identifier || + // u.Nic == identifier + //); + + //if (userTypeId != Guid.Empty) + // query = query.Where(u => u.UserTypeId == userTypeId); + + return user; + } + + public async Task GetUserByIdAsync(Guid userId, CancellationToken ct = default) + { + return await _dbContext.Users + .Include(u => u.Role) + .Include(u => u.UserType) + .FirstOrDefaultAsync(u => u.Id == userId, ct); + } + + public async Task UpdateUserAsync(User user, CancellationToken ct = default) + { + _dbContext.Users.Update(user); + await _dbContext.SaveChangesAsync(ct); + } + + public async Task> GetUserSessionsAsync(Guid userId, CancellationToken ct = default) + { + return await _dbContext.UserSessions + .Where(s => s.UserId == userId) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(ct); + } + + public async Task GetActiveSessionByRefreshTokenAsync(string refreshToken, CancellationToken ct = default) + { + var activeSessions = await _dbContext.UserSessions + .Include(s => s.User) + .Where(s => s.RevokedAt == null && s.ExpiresAt > DateTime.UtcNow) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(ct); + + foreach (var session in activeSessions) + { + if (PasswordHasher.Verify(refreshToken, session.RefreshTokenHash)) + return session; + } + + return null; + } + + public async Task InvalidateUserSessionsAsync(Guid userId, CancellationToken ct = default) + { + var userSessions = await _dbContext.UserSessions + .Where(s => s.UserId == userId && s.RevokedAt == null) + .ToListAsync(ct); + + foreach (var session in userSessions) + { + session.RevokedAt = DateTime.UtcNow; + } + + await _dbContext.SaveChangesAsync(ct); + } + + //public async Task GetExUser(string identifier) + //{ + // var userExists = await _dbContext.Users + // .AnyAsync(u => u.Email == identifier || u.MobileNumber == identifier || u.Nic == identifier); + // return userExists; + //} + + + + + + + + + + + + + + + + + + + //public async Task GetByIdAsync(int id, CancellationToken ct = default) + //{ + // return await _dbContext.TuteItems + // .AsNoTracking() + // .FirstOrDefaultAsync(x => x.Id == id, ct); + //} + + //public async Task> ListAsync(CancellationToken ct = default) + //{ + // return await _dbContext.TuteItems + // .AsNoTracking() + // .OrderByDescending(x => x.Id) + // .ToListAsync(ct); + //} + + //public async Task UpdateAsync(int id, string title, string? notes, CancellationToken ct = default) + //{ + // var entity = await _dbContext.TuteItems.FirstOrDefaultAsync(x => x.Id == id, ct); + // if (entity == null) + // return null; + + // entity.Title = title; + // entity.Notes = notes; + // return entity; + //} + + //public async Task DeleteAsync(int id, CancellationToken ct = default) + //{ + // var entity = await _dbContext.TuteItems.FirstOrDefaultAsync(x => x.Id == id, ct); + // if (entity == null) + // return false; + + // _dbContext.TuteItems.Remove(entity); + // return true; + //} + } +} diff --git a/Services/AltOptionManager/AltOptionManagerService.cs b/Services/AltOptionManager/AltOptionManagerService.cs new file mode 100644 index 0000000..95dc478 --- /dev/null +++ b/Services/AltOptionManager/AltOptionManagerService.cs @@ -0,0 +1,496 @@ +using AuthHex.Infra.UoW; +using AuthHex.Interfaces; +using AuthHex.Models; +using AuthHex.Models.IOs; +using AuthHex.Utility; +using AuthHex.Utility.passwordHasher; +using AuthSystem.API.Utils; +using Core_Archi.Helpers; +using System.Security.Cryptography; + + +namespace AuthHex.Services.AltOptionManager; + +public class AltOptionManagerService +{ + private readonly IRecoveryManagerRepository _repository; + private readonly IUserManageRepository _userRepository; + private readonly IAltOptionManagerRepository _altOptionRepository; + private readonly IUnitOfWork _uow; + private readonly JwtTokenHelper _jwt; + private readonly AppDBContext _dbContext; + private readonly ThirdPartyService _thirdPartyService; + private readonly IConfiguration _configuration; + private readonly UserHelper _userHelper; + + public AltOptionManagerService( + IRecoveryManagerRepository Recoveryrepository, + IUserManageRepository userRepository, + IAltOptionManagerRepository altOptionRepository, + IUnitOfWork uow, + JwtTokenHelper jwt, + AppDBContext dbContext, + ThirdPartyService thirdPartyService, + IConfiguration configuration, + UserHelper userHelper) + { + _repository = Recoveryrepository; + _userRepository = userRepository; + _altOptionRepository = altOptionRepository; + _uow = uow; + _jwt = jwt; + _dbContext = dbContext; + _thirdPartyService = thirdPartyService; + _configuration = configuration; + _userHelper = userHelper; + } + + + + public async Task IsAvailable(object payload, HttpContext httpContext) + { + + var data = DeserializePayload.Deserialize(payload); + var Identifier = data.ContainsKey("Identifier") ? data["Identifier"].GetString() : null; + var Recovery = data.ContainsKey("Recovery") ? data["Recovery"].GetString() : null; + + var existingUsers = await _userRepository.GetUserByIdentifiers( + email: Identifier, + mobileNumber: Identifier, + nic: Identifier, + username: Identifier, + Optional1: Identifier + ); + + if (existingUsers == null || !existingUsers.Any()) + { + return new { IsAvailable = true, Message = "Identifier is available" }; + } + + if (Recovery == null) + { + return new { IsAvailable = false, Message = "Identifier already in use" }; + } + + return new { existingUsers }; + + } + + public async Task SendOtp(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + + try + { + var data = DeserializePayload.Deserialize(payload); + + + var identifier = data.ContainsKey("identifier") ? data["identifier"].GetString() : null; + var numberOfDigits = data.ContainsKey("numberOfDigits") ? data["numberOfDigits"].GetInt32() : 6; + + var newUser = data.ContainsKey("newUser") ? data["newUser"].GetBoolean() : false; + + if (string.IsNullOrWhiteSpace(identifier)) + throw new ArgumentException("Identifier is required."); + + var otpCode = await GenerateOtpCode(numberOfDigits); + Recovery recovery; + + + if (newUser) + { + recovery = new Recovery + { + RecoveryReferenceNum = GenerateSecureReferenceNumber(), + OTP = otpCode, + Status = "Pending", + IsUsed = false, + CreatedAt = DateTime.UtcNow, + ExpirationTime = DateTime.UtcNow.AddMinutes(10), + RecoveryType = "OTP FOR NEW USER" + }; + + await _repository.AddRecoveryAsync(recovery); + await _uow.CommitAsync(); + + if (!string.IsNullOrWhiteSpace(identifier)) + { + if (IsEmail(identifier)) + { + await _thirdPartyService.EmailConfiguration( + identifier, + otpCode, + "Verification OTP", + $@" +

Verification OTP

+

Your OTP code is:

+

{otpCode}

+

This code expires in 10 minutes.

+

Reference Number: {recovery.RecoveryReferenceNum}

+ " + ); + } + else if (IsMobileNumber(identifier)) + { + await _thirdPartyService.SendSmsAsync( + identifier, + otpCode, + $"Your OTP is {otpCode}. It expires in 10 minutes. Ref: {recovery.RecoveryReferenceNum}" + ); + } + else + { + throw new ArgumentException("Invalid identifier format. Must be a valid email or mobile number."); + } + } + + return new + { + Success = true, + Message = "OTP sent successfully for new user.", + Data = new + { + ReferenceNumber = recovery.RecoveryReferenceNum, + ExpiresAt = recovery.ExpirationTime, + RecoveryType = recovery.RecoveryType + } + }; + } + + + //exisiting one + + var user = await _userRepository.GetUserByIdentifierAndType(identifier, null); + + if (user == null) + throw new KeyNotFoundException("No user found with the provided identifier."); + + if (user.IsLocked == true) + throw new InvalidOperationException("User account is locked. Please contact support."); + + if (user.IsActive == false) + throw new InvalidOperationException("User account is inactive. Please contact support."); + + recovery = new Recovery + { + UserId = user.Id, + RecoveryReferenceNum = GenerateSecureReferenceNumber(), + OTP = otpCode, + Status = "Pending", + IsUsed = false, + CreatedAt = DateTime.UtcNow, + ExpirationTime = DateTime.UtcNow.AddMinutes(10), + RecoveryType = "OTP FOR LOGIN" + }; + + await _repository.AddRecoveryAsync(recovery); + await _uow.CommitAsync(); + + if (!string.IsNullOrWhiteSpace(user.MobileNumber)) + { + await _thirdPartyService.SendSmsAsync( + user.MobileNumber, + otpCode, + $"Your login OTP is {otpCode}. It expires in 10 minutes. Ref: {recovery.RecoveryReferenceNum}" + ); + } + + if (!string.IsNullOrWhiteSpace(user.Email)) + { + await _thirdPartyService.EmailConfiguration( + user.Email, + otpCode, + "Login OTP", + $@" +

Login OTP

+

Hello {user.FullName ?? user.UserName},

+

Your OTP code is:

+

{otpCode}

+

This code expires in 10 minutes.

+

Reference Number: {recovery.RecoveryReferenceNum}

+ " + ); + } + + return new + { + Success = true, + Message = "OTP sent successfully.", + Data = new + { + ReferenceNumber = recovery.RecoveryReferenceNum, + ExpiresAt = recovery.ExpirationTime, + RecoveryType = recovery.RecoveryType + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + + public async Task VerifyOTP(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + + var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = httpContext.Request.Headers["User-Agent"].ToString(); + + + + var data = DeserializePayload.Deserialize(payload); + + Guid? userId = null; + if (data.ContainsKey("userId") && Guid.TryParse(data["userId"].GetString(), out var parsedUserId)) + { + userId = parsedUserId; + } + var referenceNumber = data.ContainsKey("referenceNumber") ? data["referenceNumber"].GetString() : null; + var otpCode = data.ContainsKey("otpCode") ? data["otpCode"].GetString() : null; + var newUser = data.ContainsKey("newUser") ? data["newUser"].GetBoolean() : false; + var identifier = data.ContainsKey("identifier") ? data["identifier"].GetString() : null; + var deviceName = data.ContainsKey("deviceName") ? data["deviceName"].GetString() : "Unknown Device"; + + + if (string.IsNullOrEmpty(referenceNumber) || string.IsNullOrEmpty(otpCode)) + throw new ArgumentException("Reference number and OTP code are required."); + + var recovery = await _repository.GetRecoveryByReferenceNumAsync(referenceNumber); + + if (recovery == null) + throw new KeyNotFoundException("Invalid reference number."); + + if (recovery.IsUsed) + throw new InvalidOperationException("This OTP has already been used."); + + if (recovery.Status != "Pending") + throw new InvalidOperationException("This OTP is no longer valid."); + + if (DateTime.UtcNow > recovery.ExpirationTime) + { + recovery.Status = "Expired"; + await _repository.UpdateRecoveryAsync(recovery); + await _uow.CommitAsync(); + throw new InvalidOperationException("This OTP has expired. Please request a new one."); + } + + if (recovery.OTP != otpCode) + throw new InvalidOperationException("Invalid OTP code."); + + + + + recovery.Status = "Verified"; + recovery.IsUsed = true; + await _repository.UpdateRecoveryAsync(recovery); + + User? user = null; + if (!string.IsNullOrWhiteSpace(identifier)) + { + user = await _userRepository.GetUserByIdentifierAndType(identifier, null); + } + + if (user is null && userId.HasValue) + { + user = await _userRepository.GetUserByIdAsync(userId.Value); + } + + if (user is null && recovery.UserId.HasValue) + { + user = await _userRepository.GetUserByIdAsync(recovery.UserId.Value); + } + + if (user is null) + throw new KeyNotFoundException("No user found for OTP verification."); + + if (!string.IsNullOrWhiteSpace(identifier)) + { + if (IsEmail(identifier)) + { + user.Email = identifier; + user.EmailVerified = true; + } + else if (IsMobileNumber(identifier)) + { + user.MobileNumber = identifier; + user.MobileNumberVerified = true; + } + else + { + user.Optional1 = identifier; + user.Optional1Verified = true; + } + } + + await _userRepository.UpdateUserAsync(user); + + var jwtToken = await _jwt.GenerateToken(user); + var refreshToken = Guid.NewGuid().ToString(); + var refreshTokenHash = PasswordHasher.Hash(refreshToken); + + var userSession = new UserSession + { + UserId = user.Id, + RefreshTokenHash = refreshTokenHash, + DeviceName = deviceName, + IPAddress = ipAddress, + Browser = _userHelper.ExtractBrowser(userAgent), + OS = _userHelper.ExtractOS(userAgent), + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow + }; + + _dbContext.UserSessions.Add(userSession); + + await LogAuthEvent(user.Id, "LOGIN_SUCCESS", ipAddress, userAgent); + + await _uow.CommitAsync(); + + + return new + { + Success = true, + Message = newUser + ? "OTP verified successfully. Session started." + : "OTP verified successfully.", + Data = new + { + ReferenceNumber = recovery.RecoveryReferenceNum, + UserId = user.Id, + Verified = true, + AccessToken = jwtToken, + RefreshToken = refreshToken, + ExpiresIn = 3600, + User = new + { + user.Id, + user.FullName, + user.UserName, + user.Email, + user.MobileNumber, + user.EmailVerified, + user.MobileNumberVerified, + user.IsMfaEnabled, + RoleId = user.RoleId, + UserTypeId = user.UserTypeId + } + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + + + + + + + + + + + + + + + + + + + + + + + private async Task GenerateOtpCode(int numberOfDigits) + { + if (numberOfDigits <= 0) + throw new ArgumentException("Number of digits must be at least 1", nameof(numberOfDigits)); + + using var rng = RandomNumberGenerator.Create(); + var otpCode = ""; + + for (int i = 0; i < numberOfDigits; i++) + { + byte[] randomBytes = new byte[4]; + rng.GetBytes(randomBytes); + uint randomValue = BitConverter.ToUInt32(randomBytes, 0); + otpCode += (randomValue % 10).ToString(); + } + + return await Task.FromResult(otpCode); + } + + + private string GenerateSecureReferenceNumber() + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + using var rng = RandomNumberGenerator.Create(); + + byte[] randomBytes = new byte[8]; + rng.GetBytes(randomBytes); + + var randomPart = Convert.ToBase64String(randomBytes) + .Replace("/", "") + .Replace("+", "") + .Replace("=", "") + .Substring(0, 8).ToUpper(); + + return $"REC{timestamp}{randomPart}"; + } + + private bool IsEmail(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + return false; + + try + { + var emailRegex = new System.Text.RegularExpressions.Regex( + @"^[^@\s]+@[^@\s]+\.[^@\s]+$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + return emailRegex.IsMatch(identifier); + } + catch + { + return false; + } + } + + private bool IsMobileNumber(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + return false; + + var cleaned = identifier.Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "").Replace("+", ""); + return System.Text.RegularExpressions.Regex.IsMatch(cleaned, @"^\d{7,15}$"); + } + + private async Task LogAuthEvent(Guid? userId, string eventType, string? ipAddress, string? userAgent) + { + var authEvent = new AuthEventLog + { + UserId = userId, + EventType = eventType, + IPAddress = ipAddress, + UserAgent = userAgent, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.AuthEventLogs.Add(authEvent); + await Task.CompletedTask; + } + +} \ No newline at end of file diff --git a/Services/FunctionHandler/AltOptionManager.cs b/Services/FunctionHandler/AltOptionManager.cs new file mode 100644 index 0000000..fab6de7 --- /dev/null +++ b/Services/FunctionHandler/AltOptionManager.cs @@ -0,0 +1,74 @@ +using AuthHex.Models; +using AuthHex.Models.IOs; +using AuthHex.Services.AltOptionManager; +using System.Text.Json; + +namespace AuthHex.Services.FunctionHandler +{ + public class AltOptionManager + { + public readonly Dictionary>> _functionHandler; + public readonly AltOptionManagerService _altOptionManagerService; + + public AltOptionManager(AltOptionManagerService altOptionManagerService) + { + _altOptionManagerService = altOptionManagerService; + _functionHandler = new() + { + {"IsAvailable", altOptionManagerService.IsAvailable}, + {"sendOtp", altOptionManagerService.SendOtp }, + {"VerifyOTP", altOptionManagerService.VerifyOTP } + }; + } + + public async Task Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (ArgumentException ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = ex.Message, + Data = null + }; + } + } + } +} diff --git a/Services/FunctionHandler/RecoveryManager.cs b/Services/FunctionHandler/RecoveryManager.cs new file mode 100644 index 0000000..580d686 --- /dev/null +++ b/Services/FunctionHandler/RecoveryManager.cs @@ -0,0 +1,73 @@ +using AuthHex.Models; +using AuthHex.Models.IOs; +using AuthHex.Services.RecoveryManager; +using System.Text.Json; + +namespace AuthHex.Services.FunctionHandler +{ + public class RecoveryManager + { + public readonly Dictionary>> _functionHandler; + + public RecoveryManager(RecoveryManagerService RecoveryManagerService) + { + _functionHandler = new() + { + { "forgotPassword", RecoveryManagerService.ForgotPassword }, + { "verifyOTP", RecoveryManagerService.VerifyOTP }, + { "resetPasswordWithToken", RecoveryManagerService.ResetPasswordWithToken }, + { "resetPassword", RecoveryManagerService.ResetPassword } + }; + } + + public async Task Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (ArgumentException ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = ex.Message, + Data = null + }; + } + } + } +} diff --git a/Services/FunctionHandler/UserManager.cs b/Services/FunctionHandler/UserManager.cs new file mode 100644 index 0000000..0c26c4b --- /dev/null +++ b/Services/FunctionHandler/UserManager.cs @@ -0,0 +1,88 @@ +using AuthHex.Models; +using AuthHex.Models.IOs; +using AuthHex.Services.UserManager; +using System.Text.Json; + +namespace AuthHex.Services.FunctionHandler +{ + public class UserManager + { + public readonly Dictionary>> _functionHandler; + + public UserManager(UserManagerService UserManagerService) + { + _functionHandler = new() + { + { "registerUser", UserManagerService.RegisterUser }, + { "loginUser", UserManagerService.LoginUser }, + { "refreshToken", UserManagerService.RefreshToken }, + { "getUserDetails", UserManagerService.GetUserDetails }, + { "getUserSessions", async (payload, context) => await UserManagerService.GetUserSessions(context) }, + { "ChangeUserStatus", UserManagerService.ChangeUserStatus}, + {"LockUserAccount" , UserManagerService.LockUserAccount }, + {"ChangeUserPassword" , UserManagerService.ChangeUserPassword }, + {"VerifyPassword" , UserManagerService.VerifyPassword }, + {"LogoutUser" , UserManagerService.LogoutUser}, + {"UpdateUser", UserManagerService.UpdateUser}, + {"VerifyOtpForLogin",UserManagerService.VerifyOtpForLogin}, + + // 2FA Management Functions + { "initiateTwoFASetup", async (payload, context) => await UserManagerService.InitiateTwoFASetup(context) }, + { "completeTwoFASetup", UserManagerService.CompleteTwoFASetup }, + { "verifyTwoFA", UserManagerService.VerifyTwoFA }, + { "disableTwoFA", UserManagerService.DisableTwoFA }, + { "getTwoFAStatus", async (payload, context) => await UserManagerService.GetTwoFAStatus(context) } + }; + } + + public async Task Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (ArgumentException ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = ex.Message, + Data = null + }; + } + } + } +} diff --git a/Services/RecoveryManager/RecoveryManagerService.cs b/Services/RecoveryManager/RecoveryManagerService.cs new file mode 100644 index 0000000..eff7471 --- /dev/null +++ b/Services/RecoveryManager/RecoveryManagerService.cs @@ -0,0 +1,520 @@ +using AuthHex.Infra.UoW; +using AuthHex.Interfaces; +using AuthHex.Models; +using AuthHex.Models.IOs; +using AuthHex.Utility.passwordHasher; +using AuthSystem.API.Utils; +using Core_Archi.Helpers; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using AuthHex.Utility; + +namespace AuthHex.Services.RecoveryManager; + +public class RecoveryManagerService +{ + private readonly IRecoveryManagerRepository _repository; + private readonly IUserManageRepository _userRepository; + private readonly IUnitOfWork _uow; + private readonly JwtTokenHelper _jwt; + private readonly AppDBContext _dbContext; + private readonly ThirdPartyService _thirdPartyService; + private readonly IConfiguration _configuration; + + public RecoveryManagerService( + IRecoveryManagerRepository Recoveryrepository, + IUserManageRepository userRepository, + IUnitOfWork uow, + JwtTokenHelper jwt, + AppDBContext dbContext, + ThirdPartyService thirdPartyService, + IConfiguration configuration) + { + _repository = Recoveryrepository; + _userRepository = userRepository; + _uow = uow; + _jwt = jwt; + _dbContext = dbContext; + _thirdPartyService = thirdPartyService; + _configuration = configuration; + } + + + public async Task ForgotPassword(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var identifier = data.ContainsKey("identifier") ? data["identifier"].GetString() : null; + var useResetLink = data.ContainsKey("useResetLink") ? data["useResetLink"].GetBoolean() : false; + var numberOfDigits = data.ContainsKey("numberOfDigits") ? data["numberOfDigits"].GetInt32() : 6; + + if (string.IsNullOrEmpty(identifier)) + throw new ArgumentException("Identifier is required."); + + var user = await _userRepository.GetUserByIdentifierAndType(identifier, null); + if (user == null) + throw new KeyNotFoundException("No user found with the provided identifier."); + + if (user.IsLocked == true) + throw new InvalidOperationException("User account is locked. Please contact support."); + + if (user.IsActive == false) + throw new InvalidOperationException("User account is inactive. Please contact support."); + + // invalidate all previous pending recovery attempts for this user .. + await _userRepository.InvalidateUserSessionsAsync(user.Id); + var pendingRecoveries = await _repository.GetPendingRecoveriesByUserIdAsync(user.Id); + foreach (var oldRecovery in pendingRecoveries) + { + oldRecovery.Status = "Expired"; + oldRecovery.IsUsed = true; + await _repository.UpdateRecoveryAsync(oldRecovery); + } + + Recovery recovery; + + if (useResetLink) + { + + var resetToken = GenerateSecureResetToken(); + var tokenHash = HashToken(resetToken); + + recovery = new Recovery + { + UserId = user.Id, + RecoveryReferenceNum = GenerateSecureReferenceNumber(), + ResetTokenHash = tokenHash, + Status = "Pending", + IsUsed = false, + CreatedAt = DateTime.UtcNow, + ExpirationTime = DateTime.UtcNow.AddMinutes(15), + RecoveryType = "ResetLink" + }; + + await _repository.AddRecoveryAsync(recovery); + await _uow.CommitAsync(); + + // send reset link via email + if (!string.IsNullOrWhiteSpace(user.Email)) + { + var frontendUrl = _configuration["AppSettings:FrontendUrl"] ?? "http://localhost:3000"; //wanna add app.setting frontend URL + var resetLink = $"{frontendUrl}/reset-password?token={resetToken}"; + + await _thirdPartyService.EmailConfiguration( + user.Email!, + resetToken, + "Password Reset Request", + $@" +

Password Reset Request

+

Hello {user.FullName ?? user.UserName},

+

You requested to reset your password. Click the link below to proceed:

+

Reset Password

+

This link will expire in 15 minutes.

+

If you didn't request this, please ignore this email.

+

Reference Number: {recovery.RecoveryReferenceNum}

+ "); + } + + return new + { + Success = true, + Message = "Password reset link sent to your email.", + Data = new + { + ReferenceNumber = recovery.RecoveryReferenceNum, + ExpiresAt = recovery.ExpirationTime, + RecoveryType = "ResetLink" + } + }; + } + else + { + + var otpCode = await GenerateOtpCode(numberOfDigits); + + recovery = new Recovery + { + UserId = user.Id, + RecoveryReferenceNum = GenerateSecureReferenceNumber(), + OTP = otpCode, + Status = "Pending", + IsUsed = false, + CreatedAt = DateTime.UtcNow, + ExpirationTime = DateTime.UtcNow.AddMinutes(10), + RecoveryType = "OTP" + }; + + await _repository.AddRecoveryAsync(recovery); + await _uow.CommitAsync(); + + + if (!string.IsNullOrWhiteSpace(user.MobileNumber)) + { + await _thirdPartyService.SendSmsAsync( + user.MobileNumber!, + otpCode, + $"Your password recovery OTP is: {otpCode}. It expires in 10 minutes. Ref: {recovery.RecoveryReferenceNum}"); + } + + + if (!string.IsNullOrWhiteSpace(user.Email)) + { + await _thirdPartyService.EmailConfiguration( + user.Email!, + otpCode, + "Password Recovery OTP", + $@" +

Password Recovery OTP

+

Hello {user.FullName ?? user.UserName},

+

Your OTP code is: {otpCode}

+

This code expires in 10 minutes.

+

Reference Number: {recovery.RecoveryReferenceNum}

+ "); + } + + return new + { + Success = true, + Message = "OTP sent successfully.", + Data = new + { + ReferenceNumber = recovery.RecoveryReferenceNum, + ExpiresAt = recovery.ExpirationTime, + RecoveryType = "OTP" + } + }; + } + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + public async Task VerifyOTP(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var referenceNumber = data.ContainsKey("referenceNumber") ? data["referenceNumber"].GetString() : null; + var otpCode = data.ContainsKey("otpCode") ? data["otpCode"].GetString() : null; + + if (string.IsNullOrEmpty(referenceNumber) || string.IsNullOrEmpty(otpCode)) + throw new ArgumentException("Reference number and OTP code are required."); + + var recovery = await _repository.GetRecoveryByReferenceNumAsync(referenceNumber); + + if (recovery == null) + throw new KeyNotFoundException("Invalid reference number."); + + if (recovery.IsUsed) + throw new InvalidOperationException("This OTP has already been used."); + + if (recovery.Status != "Pending") + throw new InvalidOperationException("This OTP is no longer valid."); + + if (DateTime.UtcNow > recovery.ExpirationTime) + { + recovery.Status = "Expired"; + await _repository.UpdateRecoveryAsync(recovery); + await _uow.CommitAsync(); + throw new InvalidOperationException("This OTP has expired. Please request a new one."); + } + + if (recovery.OTP != otpCode) + throw new InvalidOperationException("Invalid OTP code."); + + + + recovery.Status = "Verified"; + await _repository.UpdateRecoveryAsync(recovery); + await _uow.CommitAsync(); + + return new + { + Success = true, + Message = "OTP verified successfully. You can now reset your password.", + Data = new + { + ReferenceNumber = recovery.RecoveryReferenceNum, + UserId = recovery.UserId, + Verified = true + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + public async Task ResetPasswordWithToken(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var resetToken = data.ContainsKey("resetToken") ? data["resetToken"].GetString() : null; + var newPassword = data.ContainsKey("newPassword") ? data["newPassword"].GetString() : null; + var confirmPassword = data.ContainsKey("confirmPassword") ? data["confirmPassword"].GetString() : null; + + if (string.IsNullOrEmpty(resetToken)) + throw new ArgumentException("Reset token is required."); + + if (string.IsNullOrEmpty(newPassword) || string.IsNullOrEmpty(confirmPassword)) + throw new ArgumentException("New password and confirmation are required."); + + if (newPassword != confirmPassword) + throw new ArgumentException("Passwords do not match."); + + + if (newPassword.Length < 8) + throw new ArgumentException("Password must be at least 8 characters long."); + + + var tokenHash = HashToken(resetToken); + var recovery = await _repository.GetRecoveryByTokenHashAsync(tokenHash); + + if (recovery == null) + throw new KeyNotFoundException("Invalid or expired reset token."); + + if (recovery.IsUsed) + throw new InvalidOperationException("This reset link has already been used."); + + if (recovery.Status != "Pending") + throw new InvalidOperationException("This reset link is no longer valid."); + + if (DateTime.UtcNow > recovery.ExpirationTime) + { + recovery.Status = "Expired"; + recovery.IsUsed = true; + await _repository.UpdateRecoveryAsync(recovery); + await _uow.CommitAsync(); + throw new InvalidOperationException("This reset link has expired. Please request a new one."); + } + + if (recovery.RecoveryType != "ResetLink") + throw new InvalidOperationException("Invalid recovery method."); + + // Get user + var user = await _userRepository.GetUserByIdAsync((Guid)recovery.UserId); + if (user == null) + throw new KeyNotFoundException("User not found."); + + if (user.IsLocked == true) + throw new InvalidOperationException("User account is locked. Please contact support."); + + user.PasswordHash = PasswordHasher.Hash(newPassword); + + // Update user + await _dbContext.SaveChangesAsync(); + recovery.Status = "Used"; + recovery.IsUsed = true; + + await _repository.UpdateRecoveryAsync(recovery); + + // invalid all user sessions (force re-login) + await _userRepository.InvalidateUserSessionsAsync(user.Id); // can change BE requirement + + await _uow.CommitAsync(); + + + if (!string.IsNullOrWhiteSpace(user.Email)) + { + await _thirdPartyService.EmailConfiguration( + user.Email!, + "SUCCESS", + "Password Reset Successful", + $@" +

Password Reset Successful

+

Hello {user.FullName ?? user.UserName},

+

Your password has been successfully reset.

+

If you did not make this change, please contact support immediately.

+

Time: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC

+ "); + } + + return new + { + Success = true, + Message = "Password reset successful. Please login with your new password.", + Data = new + { + UserId = user.Id, + Email = user.Email, + ResetAt = DateTime.UtcNow + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + public async Task ResetPassword(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var referenceNumber = data.ContainsKey("referenceNumber") ? data["referenceNumber"].GetString() : null; + var newPassword = data.ContainsKey("newPassword") ? data["newPassword"].GetString() : null; + var confirmPassword = data.ContainsKey("confirmPassword") ? data["confirmPassword"].GetString() : null; + + if (string.IsNullOrEmpty(referenceNumber)) + throw new ArgumentException("Reference number is required."); + + if (string.IsNullOrEmpty(newPassword) || string.IsNullOrEmpty(confirmPassword)) + throw new ArgumentException("New password and confirmation are required."); + + if (newPassword != confirmPassword) + throw new ArgumentException("Passwords do not match."); + + + if (newPassword.Length < 8) + throw new ArgumentException("Password must be at least 8 characters long."); + + var recovery = await _repository.GetRecoveryByReferenceNumAsync(referenceNumber); + + if (recovery == null) + throw new KeyNotFoundException("Invalid reference number."); + + if (recovery.Status != "Verified") + throw new InvalidOperationException("Please verify your OTP first before resetting password."); + + if (recovery.IsUsed) + throw new InvalidOperationException("This recovery request has already been used."); + + if (DateTime.UtcNow > recovery.ExpirationTime) + { + recovery.Status = "Expired"; + recovery.IsUsed = true; + await _repository.UpdateRecoveryAsync(recovery); + await _uow.CommitAsync(); + throw new InvalidOperationException("This recovery request has expired."); + } + + var user = await _userRepository.GetUserByIdAsync((Guid)recovery.UserId); + if (user == null) + throw new KeyNotFoundException("User not found."); + + if (user.IsLocked == true) + throw new InvalidOperationException("User account is locked. Please contact support."); + + user.PasswordHash = PasswordHasher.Hash(newPassword); + + await _dbContext.SaveChangesAsync(); + + recovery.Status = "Used"; + recovery.IsUsed = true; + await _repository.UpdateRecoveryAsync(recovery); + + await _userRepository.InvalidateUserSessionsAsync(user.Id); //as previous comment can be changed BE requirement + + await _uow.CommitAsync(); + + // Send confirmation email + if (!string.IsNullOrWhiteSpace(user.Email)) + { + await _thirdPartyService.EmailConfiguration( + user.Email!, + "SUCCESS", + "Password Reset Successful", + $@" +

Password Reset Successful

+

Hello {user.FullName ?? user.UserName},

+

Your password has been successfully reset.

+

If you did not make this change, please contact support immediately.

+

Time: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC

+ "); + } + + return new + { + Success = true, + Message = "Password reset successful. Please login with your new password.", + Data = new + { + UserId = user.Id, + Email = user.Email, + ResetAt = DateTime.UtcNow + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + //------------------------------- HELPER METHODS ------------------------------- + + private string GenerateSecureResetToken() + { + using var rng = RandomNumberGenerator.Create(); + byte[] tokenBytes = new byte[32]; + rng.GetBytes(tokenBytes); + + // Convert to URL-safe base64 string + return Convert.ToBase64String(tokenBytes) + .Replace("+", "-") + .Replace("/", "_") + .Replace("=", ""); + } + + + private string HashToken(string token) + { + using var sha256 = SHA256.Create(); + byte[] hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(token)); + return Convert.ToBase64String(hashBytes); + } + + private async Task GenerateOtpCode(int numberOfDigits) + { + if (numberOfDigits <= 0 ) + throw new ArgumentException("Number of digits must be at least 1", nameof(numberOfDigits)); + + using var rng = RandomNumberGenerator.Create(); + var otpCode = ""; + + for (int i = 0; i < numberOfDigits; i++) + { + byte[] randomBytes = new byte[4]; + rng.GetBytes(randomBytes); + uint randomValue = BitConverter.ToUInt32(randomBytes, 0); + otpCode += (randomValue % 10).ToString(); + } + + return await Task.FromResult(otpCode); + } + + + private string GenerateSecureReferenceNumber() + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + using var rng = RandomNumberGenerator.Create(); + + byte[] randomBytes = new byte[8]; + rng.GetBytes(randomBytes); + + var randomPart = Convert.ToBase64String(randomBytes) + .Replace("/", "") + .Replace("+", "") + .Replace("=", "") + .Substring(0, 8).ToUpper(); + + return $"REC{timestamp}{randomPart}"; + } +} \ No newline at end of file diff --git a/Services/UserManager/UserManagerService.cs b/Services/UserManager/UserManagerService.cs new file mode 100644 index 0000000..e9a7623 --- /dev/null +++ b/Services/UserManager/UserManagerService.cs @@ -0,0 +1,1225 @@ +using AuthHex.Infra.UoW; +using AuthHex.Interfaces; +using AuthHex.Models; +using AuthHex.Models.IOs; +using AuthHex.Repos; +using AuthHex.Utility; +using AuthHex.Utility.passwordHasher; +using AuthSystem.API.Utils; +using Core_Archi.Helpers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using System.Net; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using static System.Runtime.InteropServices.JavaScript.JSType; + + +namespace AuthHex.Services.UserManager; + +public class UserManagerService +{ + private readonly IUserManageRepository _repository; + private readonly IRecoveryManagerRepository _recoveryRepository; + private readonly IUnitOfWork _uow; + private readonly JwtTokenHelper _jwt; + private readonly AppDBContext _dbContext; + private readonly ThirdPartyService _thirdPartyService; + private readonly UserHelper _userHelper; + + public UserManagerService(IUserManageRepository repository, IRecoveryManagerRepository recoveryRepository, IUnitOfWork uow, JwtTokenHelper jwt, AppDBContext dbContext, ThirdPartyService thirdPartyService, UserHelper userHelper) + { + _repository = repository; + _recoveryRepository = recoveryRepository; + _uow = uow; + _jwt = jwt; + _dbContext = dbContext; + _thirdPartyService = thirdPartyService; + _userHelper = userHelper; + } + + public async Task RegisterUser(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = httpContext.Request.Headers["User-Agent"].ToString(); + + var data = DeserializePayload.Deserialize(payload); + + //check payload data + if (data == null || !data.Any()) + throw new ArgumentException("Invalid payload data"); + + Guid userID = Guid.Parse(data.ContainsKey("userId") ? data["userId"].GetString() : throw new Exception("user id need")); + var FullName = data.ContainsKey("fullname") ? data["fullname"].GetString() : null; + var UserName = data.ContainsKey("userName") ? data["userName"].GetString() : null; + var Nic = data.ContainsKey("nic") ? data["nic"].GetString() : null; + var Email = data.ContainsKey("email") ? data["email"].GetString() : null; + var MobileNumber = data.ContainsKey("mobileNumber") ? data["mobileNumber"].GetString() : null; + + var deviceName = data.ContainsKey("deviceName") ? data["deviceName"].GetString() : "Unknown Device"; + + if (!data.ContainsKey("roleId") || !Guid.TryParse(data["roleId"].GetString(), out var RoleId)) + throw new ArgumentException("Invalid roleId"); + + if (!data.ContainsKey("userTypeId") || !Guid.TryParse(data["userTypeId"].GetString(), out var UserTypeId)) + throw new ArgumentException("Invalid userTypeId"); + + + //chk user exist + if (data.ContainsKey("chkUser") == true) { + var userToCheck = new User + { + FullName = FullName, + Email = Email, + MobileNumber = MobileNumber, + Nic = Nic, + RoleId = RoleId, + UserTypeId = UserTypeId + }; + bool canSave = await CanRegisterUser(userToCheck); + + if (!canSave) + throw new InvalidOperationException("User already exists"); + } + + + // if send password empty generate random password + if (!data.ContainsKey("password") || string.IsNullOrWhiteSpace(data["password"].GetString())) + { + data["password"] = JsonDocument.Parse($"\"{Guid.NewGuid().ToString().Substring(0, 8)}\"").RootElement; + } + + var PasswordHash = data.ContainsKey("password") ? PasswordHasher.Hash(data["password"].GetString()!) : null; + + + // Create User object + User user = new User + { + Id = userID, + FullName = FullName, + UserName = UserName, + Nic = Nic, + Email = Email, + MobileNumber = MobileNumber, + RoleId = RoleId, + UserTypeId = UserTypeId, + CreatedAt = DateTime.UtcNow, + IsActive = true, + IsLocked = false, + EmailVerified = false, + MobileNumberVerified = false, + //PasswordHash = PasswordHash + + }; + + await _repository.AddUserAsync(user); + + var jwtToken = await _jwt.GenerateToken(user); + var refreshToken = Guid.NewGuid().ToString(); + var refreshTokenHash = PasswordHasher.Hash(refreshToken); + var jwtTokenHash = PasswordHasher.Hash(jwtToken); + + var token = new Token + { + UserId = user.Id, + TokenHash = jwtToken + }; + + + var userSession = new UserSession + { + UserId = user.Id, + RefreshTokenHash = refreshTokenHash, + DeviceName = deviceName, + IPAddress = ipAddress, + Browser = _userHelper.ExtractBrowser(userAgent), + OS = _userHelper.ExtractOS(userAgent), + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow + }; + + _dbContext.UserSessions.Add(userSession); + _dbContext.Token.Add(token); + + + await LogAuthEvent(user.Id, "LOGIN_SUCCESS", ipAddress, userAgent); + + await _uow.CommitAsync(); + + return new + { + AccessToken = jwtToken, + RefreshToken = refreshToken, + ExpiresIn = 3600, + User = new + { + user.Id, + user.FullName, + user.UserName, + user.Email, + user.MobileNumber, + user.EmailVerified, + user.MobileNumberVerified, + user.IsMfaEnabled, + RoleId = user.RoleId, + UserTypeId = user.UserTypeId + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + + } + + + + + + public async Task LoginUser(object payload, HttpContext httpContext) + { + var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = httpContext.Request.Headers["User-Agent"].ToString(); + + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var identifier = data.ContainsKey("identifier") ? data["identifier"].GetString() : null; + var password = data.ContainsKey("password") ? data["password"].GetString() : null; + var userTypeId = data.ContainsKey("userTypeId") && Guid.TryParse(data["userTypeId"].GetString(), out var utid) ? utid : (Guid?)null; + var deviceName = data.ContainsKey("deviceName") ? data["deviceName"].GetString() : "Unknown Device"; + + if (string.IsNullOrWhiteSpace(identifier)) + throw new ArgumentException("Identifier and password are required"); + + var user = await _repository.GetUserByIdentifierAndType(identifier, userTypeId); + + + + if (user is null) + { + + await LogAuthEvent(null, "LOGIN_FAILED", ipAddress, userAgent); + throw new ArgumentException("Invalid credentials"); + } + + + if (user.IsLocked == true) + { + await LogAuthEvent(user.Id, "LOGIN_FAILED_LOCKED", ipAddress, userAgent); + throw new ArgumentException("Account is locked"); + } + + + if (user.IsActive == false) + { + await LogAuthEvent(user.Id, "LOGIN_FAILED_INACTIVE", ipAddress, userAgent); + throw new ArgumentException("Account is deactivated"); + } + + + if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(password, user.PasswordHash)) + { + await LogAuthEvent(user.Id, "LOGIN_FAILED", ipAddress, userAgent); + throw new ArgumentException("Invalid credentials"); + } + + + var jwtToken = await _jwt.GenerateToken(user); + + + var refreshToken = Guid.NewGuid().ToString(); + var refreshTokenHash = PasswordHasher.Hash(refreshToken); + + + var userSession = new UserSession + { + UserId = user.Id, + RefreshTokenHash = refreshTokenHash, + DeviceName = deviceName, + IPAddress = ipAddress, + Browser = _userHelper.ExtractBrowser(userAgent), + OS = _userHelper.ExtractOS(userAgent), + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow + }; + + _dbContext.UserSessions.Add(userSession); + + + await LogAuthEvent(user.Id, "LOGIN_SUCCESS", ipAddress, userAgent); + + await _uow.CommitAsync(); + + return new + { + AccessToken = jwtToken, + RefreshToken = refreshToken, + ExpiresIn = 3600, + User = new + { + user.Id, + user.FullName, + user.UserName, + user.Email, + user.MobileNumber, + user.EmailVerified, + user.MobileNumberVerified, + user.IsMfaEnabled, + RoleId = user.RoleId, + UserTypeId = user.UserTypeId + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + + + public async Task VerifyOtpForLogin(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = httpContext.Request.Headers["User-Agent"].ToString(); + + var data = DeserializePayload.Deserialize(payload); + + var referenceNumber = data.ContainsKey("referenceNumber") ? data["referenceNumber"].GetString() : null; + var otpCode = data.ContainsKey("otpCode") ? data["otpCode"].GetString() : null; + var deviceName = data.ContainsKey("deviceName") ? data["deviceName"].GetString() : "Unknown Device"; + + if (string.IsNullOrEmpty(referenceNumber) || string.IsNullOrEmpty(otpCode)) + throw new ArgumentException("Reference number and OTP code are required."); + + var recovery = await _recoveryRepository.GetRecoveryByReferenceNumAsync(referenceNumber); + + if (recovery == null) + throw new KeyNotFoundException("Invalid reference number."); + + if (recovery.IsUsed) + throw new InvalidOperationException("This OTP has already been used."); + + if (recovery.Status != "Pending") + throw new InvalidOperationException("This OTP is no longer valid."); + + if (DateTime.UtcNow > recovery.ExpirationTime) + { + recovery.Status = "Expired"; + await _recoveryRepository.UpdateRecoveryAsync(recovery); + throw new InvalidOperationException("This OTP has expired. Please request a new one."); + } + + if (recovery.OTP != otpCode) + throw new InvalidOperationException("Invalid OTP code."); + + recovery.Status = "Verified"; + recovery.IsUsed = true; + await _recoveryRepository.UpdateRecoveryAsync(recovery); + + if (!recovery.UserId.HasValue) + throw new KeyNotFoundException("No user linked to this OTP."); + + var user = await _repository.GetUserByIdAsync(recovery.UserId.Value); + + if (user is null) + throw new KeyNotFoundException("User not found."); + + var jwtToken = await _jwt.GenerateToken(user); + var refreshToken = Guid.NewGuid().ToString(); + var refreshTokenHash = PasswordHasher.Hash(refreshToken); + + var userSession = new UserSession + { + UserId = user.Id, + RefreshTokenHash = refreshTokenHash, + DeviceName = deviceName, + IPAddress = ipAddress, + Browser = _userHelper.ExtractBrowser(userAgent), + OS = _userHelper.ExtractOS(userAgent), + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow + }; + + _dbContext.UserSessions.Add(userSession); + + await LogAuthEvent(user.Id, "LOGIN_SUCCESS", ipAddress, userAgent); + + await _uow.CommitAsync(); + + return new + { + Success = true, + Message ="OTP verified successfully.", + Data = new + { + ReferenceNumber = recovery.RecoveryReferenceNum, + UserId = user.Id, + Verified = true, + AccessToken = jwtToken, + RefreshToken = refreshToken, + ExpiresIn = 3600, + User = new + { + user.Id, + user.FullName, + user.UserName, + user.Email, + user.MobileNumber, + user.EmailVerified, + user.MobileNumberVerified, + user.IsMfaEnabled, + RoleId = user.RoleId, + UserTypeId = user.UserTypeId + } + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task RefreshToken(object payload, HttpContext httpContext) + { + var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString(); + var userAgent = httpContext.Request.Headers["User-Agent"].ToString(); + + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var refreshToken = data.ContainsKey("refreshToken") ? data["refreshToken"].GetString() : null; + var requestedDeviceName = data.ContainsKey("deviceName") ? data["deviceName"].GetString() : null; + + if (string.IsNullOrWhiteSpace(refreshToken)) + throw new ArgumentException("refreshToken is required"); + + var existingSession = await _repository.GetActiveSessionByRefreshTokenAsync(refreshToken); + + if (existingSession == null) + { + await LogAuthEvent(null, "REFRESH_FAILED", ipAddress, userAgent); + throw new ArgumentException("Invalid or expired refresh token"); + } + + var user = existingSession.User; + + if (user == null) + throw new ArgumentException("Invalid session user"); + + if (user.IsLocked == true) + { + await LogAuthEvent(user.Id, "REFRESH_FAILED_LOCKED", ipAddress, userAgent); + throw new ArgumentException("Account is locked"); + } + + if (user.IsActive == false) + { + await LogAuthEvent(user.Id, "REFRESH_FAILED_INACTIVE", ipAddress, userAgent); + throw new ArgumentException("Account is deactivated"); + } + + existingSession.RevokedAt = DateTime.UtcNow; + + var newAccessToken = await _jwt.GenerateToken(user); + var newRefreshToken = Guid.NewGuid().ToString(); + var newRefreshTokenHash = PasswordHasher.Hash(newRefreshToken); + + var rotatedSession = new UserSession + { + UserId = user.Id, + RefreshTokenHash = newRefreshTokenHash, + DeviceName = string.IsNullOrWhiteSpace(requestedDeviceName) ? existingSession.DeviceName : requestedDeviceName, + IPAddress = ipAddress, + Browser = _userHelper.ExtractBrowser(userAgent), + OS = _userHelper.ExtractOS(userAgent), + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow + }; + + _dbContext.UserSessions.Add(rotatedSession); + + await LogAuthEvent(user.Id, "REFRESH_SUCCESS", ipAddress, userAgent); + + await _uow.CommitAsync(); + + return new + { + AccessToken = newAccessToken, + RefreshToken = newRefreshToken, + ExpiresIn = 3600, + User = new + { + user.Id, + user.FullName, + user.UserName, + user.Email, + user.MobileNumber, + user.EmailVerified, + user.MobileNumberVerified, + user.IsMfaEnabled, + RoleId = user.RoleId, + UserTypeId = user.UserTypeId + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + + + + + + public async Task GetUserDetails(object payload , HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + + Guid userId = data.ContainsKey("userId") ? data["userId"].GetGuid() : throw new Exception("UserId required"); + + var user = await _repository.GetUserByIdAsync(userId); + + if (user == null) + { + throw new ArgumentException("User not found"); + } + + return new + { + user.Id, + user.FullName, + user.UserName, + user.Email, + user.Nic, + user.MobileNumber, + user.EmailVerified, + user.MobileNumberVerified, + user.IsMfaEnabled, + user.IsActive, + user.IsLocked, + user.CreatedAt, + Role = new + { + user.Role?.RoleId, + user.Role?.Code, + user.Role?.Name + }, + UserType = new + { + user.UserType?.UserTypeId, + user.UserType?.Code, + user.UserType?.Description + } + }; + } + catch + { + throw; + } + } + + public async Task GetUserSessions(HttpContext httpContext) + { + try + { + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var sessions = await _repository.GetUserSessionsAsync(userId); + + return sessions.Select(s => new + { + s.SessionId, + s.DeviceName, + s.Browser, + s.OS, + s.IPAddress, + s.CreatedAt, + s.ExpiresAt, + s.RevokedAt, + s.IsActive + }).ToList(); + } + catch + { + throw; + } + } + + public async Task ChangeUserStatus(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var isActive = data.ContainsKey("isActive") ? data["isActive"].GetBoolean() : (bool?)null; + + if (isActive == null) + throw new ArgumentException("isActive is required"); + + var user = await _repository.GetUserByIdAsync(userId); + + if (user == null) + throw new ArgumentException("User not found"); + + user.IsActive = isActive.Value; + + await _uow.CommitAsync(); + + return new + { + user.Id, + user.FullName, + user.UserName, + user.Email, + user.MobileNumber, + user.IsActive + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + public async Task LockUserAccount(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var isLocked = data.ContainsKey("isLocked") ? data["isLocked"].GetBoolean() : (bool?)null; + + if (isLocked == null) + throw new ArgumentException("isLocked is required"); + + var user = await _repository.GetUserByIdAsync(userId); + + if (user == null) + throw new ArgumentException("User not found"); + + user.IsLocked = isLocked.Value; + user.DeletedAt = DateTime.UtcNow; + + await _uow.CommitAsync(); + await _repository.InvalidateUserSessionsAsync(user.Id); + + return new + { + user.Id, + user.FullName, + user.UserName, + user.Email, + user.MobileNumber, + user.IsLocked, + user.DeletedAt + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + + public async Task VerifyPassword(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var password = data.ContainsKey("password") ? data["password"].GetString() : null; + if (string.IsNullOrWhiteSpace(password)) + throw new ArgumentException("Password is required"); + + var user = await _repository.GetUserByIdAsync(userId); + if (user == null) + throw new ArgumentException("User not found"); + + bool valid = !string.IsNullOrEmpty(user.PasswordHash) && PasswordHasher.Verify(password, user.PasswordHash); + return new { valid }; + } + + public async Task ChangeUserPassword(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var currentPassword = data.ContainsKey("currentPassword") ? data["currentPassword"].GetString() : null; + var newPassword = data.ContainsKey("newPassword") ? data["newPassword"].GetString() : null; + + if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword)) + throw new ArgumentException("Current password and new password are required"); + + var user = await _repository.GetUserByIdAsync(userId); + + if (user == null) + throw new ArgumentException("User not found"); + + + if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(currentPassword, user.PasswordHash)) + { + throw new ArgumentException("Current password is incorrect"); + } + + user.PasswordHash = PasswordHasher.Hash(newPassword); + + await _dbContext.SaveChangesAsync(); + + await _repository.InvalidateUserSessionsAsync(user.Id); + + await _uow.CommitAsync(); + + return new + { + message = "Password changed successfully" + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + public async Task LogoutUser(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = Guid.Parse(data.ContainsKey("userId") ? data["userId"].GetString() : throw new Exception("user id need")); + + await _repository.InvalidateUserSessionsAsync(userId); + + await _uow.CommitAsync(); + + return new + { + message = "User logged out successfully" + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + public async Task UpdateUser(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (data == null || !data.Any()) + throw new ArgumentException("Invalid payload data"); + + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var user = await _repository.GetUserByIdAsync(userId); + + if (user == null) + throw new ArgumentException("User not found"); + + var updated = false; + + if (data.ContainsKey("fullName")) + { + user.FullName = data["fullName"].GetString(); + updated = true; + } + + if (data.ContainsKey("userName")) + { + user.UserName = data["userName"].GetString(); + updated = true; + } + + if (data.ContainsKey("nic")) + { + user.Nic = data["nic"].GetString(); + updated = true; + } + + if (data.ContainsKey("address")) + { + user.Address = data["address"].GetString(); + updated = true; + } + + if (data.ContainsKey("optional1")) + { + user.Optional1 = data["optional1"].GetString(); + updated = true; + } + + if (data.ContainsKey("optional2")) + { + user.Optional2 = data["optional2"].GetString(); + updated = true; + } + + if (data.ContainsKey("email")) + { + var newEmail = data["email"].ValueKind == JsonValueKind.Null ? null : data["email"].GetString(); + if (!string.Equals(user.Email, newEmail, StringComparison.Ordinal)) + { + user.Email = newEmail; + user.EmailVerified = false; + } + + updated = true; + } + + if (data.ContainsKey("mobileNumber")) + { + var newMobile = data["mobileNumber"].ValueKind == JsonValueKind.Null ? null : data["mobileNumber"].GetString(); + if (!string.Equals(user.MobileNumber, newMobile, StringComparison.Ordinal)) + { + user.MobileNumber = newMobile; + user.MobileNumberVerified = false; + } + + updated = true; + } + + if (data.ContainsKey("newPassword")) + { + var currentPassword = data.ContainsKey("currentPassword") ? data["currentPassword"].GetString() : null; + var newPassword = data["newPassword"].GetString(); + + if (string.IsNullOrWhiteSpace(newPassword)) + throw new ArgumentException("New password is required for password update"); + + if (string.IsNullOrEmpty(user.PasswordHash)) + { + user.PasswordHash = PasswordHasher.Hash(newPassword); + updated = true; + } + else + { + if (string.IsNullOrWhiteSpace(currentPassword)) + throw new ArgumentException("Current password is required to change existing password"); + + if (!PasswordHasher.Verify(currentPassword, user.PasswordHash)) + throw new ArgumentException("Current password is incorrect"); + + user.PasswordHash = PasswordHasher.Hash(newPassword); + updated = true; + + await _repository.InvalidateUserSessionsAsync(user.Id); + } + } + + if (!updated) + throw new ArgumentException("No valid fields provided for update"); + + await _uow.CommitAsync(); + + return new + { + message = "User updated successfully", + user = new + { + user.Id, + user.FullName, + user.UserName, + user.Nic, + user.Address, + user.Optional1, + user.Optional2, + user.Email, + user.EmailVerified, + user.MobileNumber, + user.MobileNumberVerified + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + +//------------------------------------2FA Functions------------------------------------------------ + + public async Task InitiateTwoFASetup(HttpContext httpContext) + { + try + { + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var user = await _repository.GetUserByIdAsync(userId); + + if (user == null) + throw new ArgumentException("User not found"); + + if (user.IsMfaEnabled) + throw new InvalidOperationException("2FA is already enabled for this user"); + + if (string.IsNullOrWhiteSpace(user.Email)) + throw new ArgumentException("Email is required for 2FA setup"); + + // Generate setup data using ThirdPartyService + var setupResult = await _thirdPartyService.InitiateTwoFASetup(user.Email, user.FullName ?? user.UserName); + + return setupResult; + } + catch + { + throw; + } + } + + public async Task CompleteTwoFASetup(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var secretKey = data.ContainsKey("secretKey") ? data["secretKey"].GetString() : null; + var verificationCode = data.ContainsKey("verificationCode") ? data["verificationCode"].GetString() : null; + + if (string.IsNullOrWhiteSpace(secretKey) || string.IsNullOrWhiteSpace(verificationCode)) + throw new ArgumentException("Secret key and verification code are required"); + + var user = await _repository.GetUserByIdAsync(userId); + + if (user == null) + throw new ArgumentException("User not found"); + + if (user.IsMfaEnabled) + throw new InvalidOperationException("2FA is already enabled for this user"); + + // Verify the code using ThirdPartyService + var verificationResult = await _thirdPartyService.CompleteTwoFASetup(secretKey, verificationCode, user.Email); + + if (verificationResult.StatusCode != 200) + throw new ArgumentException("Invalid verification code. Please check your authenticator app and try again."); + + // Get backup codes from verification result + var resultData = JsonSerializer.Deserialize(JsonSerializer.Serialize(verificationResult.Data)); + var backupCodes = resultData.GetProperty("backupCodes").EnumerateArray().Select(x => x.GetString()).ToArray(); + + // Create or update UserMfa record + var existingMfa = await _dbContext.UserMfas.FirstOrDefaultAsync(m => m.UserId == userId); + + if (existingMfa != null) + { + // Update existing record + existingMfa.SecretKey = secretKey; + existingMfa.IsEnabled = true; + existingMfa.IsVerified = true; + existingMfa.VerifiedAt = DateTime.UtcNow; + existingMfa.BackupCodes = JsonSerializer.Serialize(backupCodes); + existingMfa.UpdatedAt = DateTime.UtcNow; + } + else + { + // Create new record + var userMfa = new UserMfa + { + UserId = userId, + SecretKey = secretKey, + IsEnabled = true, + IsVerified = true, + VerifiedAt = DateTime.UtcNow, + BackupCodes = JsonSerializer.Serialize(backupCodes), + CreatedAt = DateTime.UtcNow + }; + + _dbContext.UserMfas.Add(userMfa); + } + + // Update user's MFA flag + user.IsMfaEnabled = true; + + await _uow.CommitAsync(); + + return new + { + message = "2FA setup completed successfully", + backupCodes = backupCodes, + user = new + { + user.Id, + user.FullName, + user.UserName, + user.Email, + user.IsMfaEnabled + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task VerifyTwoFA(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var verificationCode = data.ContainsKey("verificationCode") ? data["verificationCode"].GetString() : null; + + if (string.IsNullOrWhiteSpace(verificationCode)) + throw new ArgumentException("Verification code is required"); + + var user = await _repository.GetUserByIdAsync(userId); + + if (user == null) + throw new ArgumentException("User not found"); + + if (!user.IsMfaEnabled) + throw new InvalidOperationException("2FA is not enabled for this user"); + + var userMfa = await _dbContext.UserMfas.FirstOrDefaultAsync(m => m.UserId == userId && m.IsEnabled == true); + + if (userMfa == null || string.IsNullOrEmpty(userMfa.SecretKey)) + throw new InvalidOperationException("2FA configuration not found"); + + // Verify using ThirdPartyService + var verificationResult = await _thirdPartyService.VerifyTwoFACode(userMfa.SecretKey, verificationCode); + + if (verificationResult.StatusCode == 200) + { + // Update last used timestamp + userMfa.LastUsedAt = DateTime.UtcNow; + userMfa.UpdatedAt = DateTime.UtcNow; + await _dbContext.SaveChangesAsync(); + } + + return verificationResult; + } + catch + { + throw; + } + } + + public async Task DisableTwoFA(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var verificationCode = data.ContainsKey("verificationCode") ? data["verificationCode"].GetString() : null; + + if (string.IsNullOrWhiteSpace(verificationCode)) + throw new ArgumentException("Verification code is required to disable 2FA"); + + var user = await _repository.GetUserByIdAsync(userId); + + if (user == null) + throw new ArgumentException("User not found"); + + if (!user.IsMfaEnabled) + throw new InvalidOperationException("2FA is not enabled for this user"); + + var userMfa = await _dbContext.UserMfas.FirstOrDefaultAsync(m => m.UserId == userId && m.IsEnabled == true); + + if (userMfa == null || string.IsNullOrEmpty(userMfa.SecretKey)) + throw new InvalidOperationException("2FA configuration not found"); + + // Verify using ThirdPartyService before disabling + var disableResult = await _thirdPartyService.DisableTwoFA(userMfa.SecretKey, verificationCode); + + if (disableResult.StatusCode != 200) + throw new ArgumentException("Invalid verification code. Cannot disable 2FA without valid code."); + + // Disable 2FA + userMfa.IsEnabled = false; + userMfa.UpdatedAt = DateTime.UtcNow; + user.IsMfaEnabled = false; + + await _uow.CommitAsync(); + + return new + { + message = "2FA has been disabled successfully", + user = new + { + user.Id, + user.FullName, + user.UserName, + user.Email, + user.IsMfaEnabled + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetTwoFAStatus(HttpContext httpContext) + { + try + { + var userId = _userHelper.GetUserIdFromClaims(httpContext); + + var user = await _repository.GetUserByIdAsync(userId); + + if (user == null) + throw new ArgumentException("User not found"); + + var userMfa = await _dbContext.UserMfas.FirstOrDefaultAsync(m => m.UserId == userId); + + return new + { + isMfaEnabled = user.IsMfaEnabled, + isVerified = userMfa?.IsVerified ?? false, + lastUsedAt = userMfa?.LastUsedAt, + verifiedAt = userMfa?.VerifiedAt, + hasBackupCodes = !string.IsNullOrEmpty(userMfa?.BackupCodes) + }; + } + catch + { + throw; + } + } + + + + + + + + + + + + + + + + + + + + + + + + + //------------------------------- HELPER FUNCTIONS ---------------------------------// + + + public async Task CanRegisterUser(User userToCheck) + { + var existingUsers = await _repository.GetUserByIdentifiers(userToCheck.Email, userToCheck.MobileNumber, userToCheck.Nic, userToCheck.UserName, userToCheck.Optional1); + + if (!existingUsers.Any()) + return true; + + foreach (var user in existingUsers) + { + if (user.UserTypeId == userToCheck.UserTypeId) + return false; + + if (user.RoleId == userToCheck.RoleId) + return false; + + if (user.IsLocked == false) + return false; + + if (user.IsActive == false) + return false; + } + + return true; + } + + + + private async Task LogAuthEvent(Guid? userId, string eventType, string? ipAddress, string? userAgent) + { + var authEvent = new AuthEventLog + { + UserId = userId, + EventType = eventType, + IPAddress = ipAddress, + UserAgent = userAgent, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.AuthEventLogs.Add(authEvent); + await Task.CompletedTask; + } + + private bool IsEmail(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) return false; + return System.Text.RegularExpressions.Regex.IsMatch( + identifier, + @"^[^@\s]+@[^@\s]+\.[^@\s]+$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + + private bool IsMobileNumber(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) return false; + var cleaned = identifier.Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "").Replace("+", ""); + return System.Text.RegularExpressions.Regex.IsMatch(cleaned, @"^\d{7,15}$"); + } + +} diff --git a/Utility/JwtTokenHelper.cs b/Utility/JwtTokenHelper.cs new file mode 100644 index 0000000..9e04732 --- /dev/null +++ b/Utility/JwtTokenHelper.cs @@ -0,0 +1,101 @@ +using AuthHex; +using AuthHex.Models; +using AuthHex.Utility; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace AuthSystem.API.Utils; + +public class JwtTokenHelper +{ + private readonly IConfiguration _config; + private readonly AppDBContext _dbContext; + private readonly RsaKeyProvider _rsaKeyProvider; + + public JwtTokenHelper(IConfiguration config, AppDBContext dbContext, RsaKeyProvider rsaKeyProvider) + { + _config = config; + _dbContext = dbContext; + _rsaKeyProvider = rsaKeyProvider; + } + + public async Task GenerateToken(User user) + { + // Load user's role and usertype if not already loaded + if (user.Role == null) + { + await _dbContext.Entry(user) + .Reference(u => u.Role) + .LoadAsync(); + } + if (user.UserType == null) + { + await _dbContext.Entry(user) + .Reference(u => u.UserType) + .LoadAsync(); + } + + var claims = new List + { + new Claim("UserId", user.Id.ToString()), + new Claim("UserTypeId", user.UserTypeId.ToString()), + new Claim("UserTypeCode", user.UserType?.Code ?? ""), + new Claim("RoleId", user.RoleId.ToString()), + new Claim("RoleCode", user.Role?.Code ?? ""), + new Claim("NIC", user.Nic ?? ""), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Iat, + new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds().ToString(), + ClaimValueTypes.Integer64) + }; + + var privateKey = _rsaKeyProvider.GetPrivateKey(); + var creds = new SigningCredentials(privateKey, SecurityAlgorithms.RsaSha256); + + var token = new JwtSecurityToken( + issuer: _config["Jwt:Issuer"], + audience: _config["Jwt:Audience"], + claims: claims, + expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(_config["Jwt:ExpiresInMinutes"]!)), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public Guid ExtractUserIdFromToken(string token) + { + try + { + var tokenHandler = new JwtSecurityTokenHandler(); + var publicKey = _rsaKeyProvider.GetPublicKey(); + + tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = publicKey, + ValidateIssuer = true, + ValidIssuer = _config["Jwt:Issuer"], + ValidateAudience = true, + ValidAudience = _config["Jwt:Audience"], + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }, out SecurityToken validatedToken); + + var jwtToken = (JwtSecurityToken)validatedToken; + var userIdClaim = jwtToken.Claims.FirstOrDefault(x => x.Type == "UserId"); + + if (userIdClaim != null && Guid.TryParse(userIdClaim.Value, out Guid userId)) + { + return userId; + } + + return Guid.Empty; + } + catch + { + return Guid.Empty; + } + } +} \ No newline at end of file diff --git a/Utility/PasswordHasher.cs b/Utility/PasswordHasher.cs new file mode 100644 index 0000000..0664e14 --- /dev/null +++ b/Utility/PasswordHasher.cs @@ -0,0 +1,9 @@ +using BCrypt.Net; + +namespace AuthHex.Utility.passwordHasher; + +public static class PasswordHasher +{ + public static string Hash(string password) => BCrypt.Net.BCrypt.HashPassword(password); + public static bool Verify(string password, string hash) => BCrypt.Net.BCrypt.Verify(password, hash); +} diff --git a/Utility/RsaKeyProvider.cs b/Utility/RsaKeyProvider.cs new file mode 100644 index 0000000..134e2a0 --- /dev/null +++ b/Utility/RsaKeyProvider.cs @@ -0,0 +1,53 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace AuthHex.Utility; + +public class RsaKeyProvider +{ + private readonly RSA _rsa; + + public RsaKeyProvider(string privateKeyXml) + { + _rsa = RSA.Create(); + _rsa.FromXmlString(privateKeyXml); + } + + public RsaSecurityKey GetPrivateKey() + { + var key = new RsaSecurityKey(_rsa); + var publicParameters = _rsa.ExportParameters(false); + key.KeyId = ComputeKeyId(publicParameters); + return key; + } + + public RsaSecurityKey GetPublicKey() + { + var publicKeyRsa = RSA.Create(); + var publicParameters = _rsa.ExportParameters(false); + publicKeyRsa.ImportParameters(publicParameters); + + var key = new RsaSecurityKey(publicKeyRsa); + key.KeyId = ComputeKeyId(publicParameters); + return key; + } + + private static string ComputeKeyId(RSAParameters publicParameters) + { + var modulus = publicParameters.Modulus ?? Array.Empty(); + var exponent = publicParameters.Exponent ?? Array.Empty(); + var payload = $"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}"; + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); + return Base64UrlEncoder.Encode(hash); + } + + public static (string privateKey, string publicKey) GenerateKeys() + { + using var rsa = RSA.Create(2048); + var privateKey = rsa.ToXmlString(true); // includes private key + var publicKey = rsa.ToXmlString(false); // public key only + return (privateKey, publicKey); + } +} diff --git a/Utility/ThirdPartyService.cs b/Utility/ThirdPartyService.cs new file mode 100644 index 0000000..2964183 --- /dev/null +++ b/Utility/ThirdPartyService.cs @@ -0,0 +1,567 @@ +using AuthHex.Models.IOs; +using AuthHex.DTOs; +using System.Text.Json; +using System.Net.Mail; +using System.Net; +using System.Security.Cryptography; +using System.Text; + +namespace AuthHex.Utility +{ + public class ThirdPartyService + { + private readonly IConfiguration _config; + private readonly HttpClient _httpClient; + + public ThirdPartyService(IConfiguration config, HttpClient httpClient) + { + _config = config; + _httpClient = httpClient; + } + + public async Task EmailConfiguration(string email, string otpCode, string? subject = null, string? body = null) + { + try + { + if (string.IsNullOrWhiteSpace(email)) + { + return new ApiResponse + { + StatusCode = 400, + Message = "Email is required" + }; + } + + if (string.IsNullOrWhiteSpace(otpCode)) + { + return new ApiResponse + { + StatusCode = 400, + Message = "OTP code is required" + }; + } + + var testEmail = email; + + var result = await SendEmailAsync(testEmail, otpCode, subject, body); + + return new ApiResponse + { + StatusCode = result.Success ? 200 : 500, + Data = new + { + testEmail, + success = result.Success + }, + Message = result.Message + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = ex.Message + }; + } + } + + public async Task SendSmsAsync(string mobileNumber, string otpCode, string? message = null) + { + try + { + if (string.IsNullOrWhiteSpace(mobileNumber)) + { + return new OtpResponse + { + StatusCode = 400, + Message = "Mobile number is required" + }; + } + + if (string.IsNullOrWhiteSpace(otpCode)) + { + return new OtpResponse + { + StatusCode = 400, + Message = "OTP code is required" + }; + } + + string contactNumber = mobileNumber; + string apiUrl = _config["Sms:ApiUrl"] ?? "https://backup.introps.com/Sms_common/send_sms"; + string apiKey = Environment.GetEnvironmentVariable("SMS_API_KEY") ?? _config["Sms:ApiKey"] ?? "8989898989"; + + var msgText = string.IsNullOrWhiteSpace(message) ? $"Your otp code is: {otpCode}" : message; + + var smsRequest = new SmsRequest + { + api_key = apiKey, + phone_number = contactNumber, + msg_text = msgText + }; + + var formContent = new FormUrlEncodedContent(new[] + { + new KeyValuePair("api_key", smsRequest.api_key), + new KeyValuePair("phone_number", smsRequest.phone_number), + new KeyValuePair("msg_text", smsRequest.msg_text) + }); + + var response = await _httpClient.PostAsync(apiUrl, formContent); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode && responseContent.Contains("agent:d")) + { + string jsonString = responseContent.Split(new[] { "agent:d" }, StringSplitOptions.None)[1]; + + var responseJson = JsonSerializer.Deserialize>(jsonString); + + if (responseJson is not null && + responseJson.TryGetValue("stt", out var statusValue) && + statusValue?.ToString() == "ok") + { + Console.WriteLine($"SMS OTP {otpCode} sent to {contactNumber}"); + return new OtpResponse + { + StatusCode = 200, + Message = "OTP sent successfully." + }; + } + } + + Console.WriteLine($"Failed to send SMS OTP to {contactNumber}. Response: {responseContent}"); + return new OtpResponse + { + StatusCode = 500, + Message = "Failed to send OTP.", + ApiResponse = responseContent + }; + } + catch (Exception ex) + { + Console.WriteLine($"Exception sending OTP: {ex.Message}"); + + return new OtpResponse + { + StatusCode = 500, + Message = "Exception occurred while sending OTP.", + Error = ex.Message + }; + } + } + + private async Task<(bool Success, string Message)> SendEmailAsync(string email, string code, string? subject = null, string? body = null) + { + try + { + if (string.IsNullOrWhiteSpace(email)) + { + return (false, "Email address is required"); + } + + string smtpHost = _config["SmtpSettings:SmtpServer"] ?? "smtp.hostinger.com"; + int smtpPort = int.Parse(_config["SmtpSettings:SmtpPort"] ?? "587"); + string? senderEmail = _config["SmtpSettings:SenderEmail"] ?? Environment.GetEnvironmentVariable("SMTP_USER"); + string? senderPassword = _config["SmtpSettings:SmtpPassword"] ?? Environment.GetEnvironmentVariable("SMTP_PASSWORD"); + string senderName = _config["SmtpSettings:SenderName"] ?? "Hexa Fitness"; + + if (string.IsNullOrWhiteSpace(senderEmail) || string.IsNullOrWhiteSpace(senderPassword)) + { + return (false, "Email configuration is missing"); + } + + using (var smtpClient = new SmtpClient(smtpHost, smtpPort)) + { + smtpClient.Credentials = new NetworkCredential(senderEmail, senderPassword); + smtpClient.EnableSsl = true; + smtpClient.Timeout = 10000; + + var emailSubject = string.IsNullOrWhiteSpace(subject) ? "Your OTP Code" : subject; + + var emailBody = string.IsNullOrWhiteSpace(body) ? $@" + + +

OTP Verification

+

Your OTP code is:

+

{code}

+

This code will expire in 10 minutes.

+

If you did not request this code, please ignore this email.

+ + " : body.Replace("{code}", code); + + var mailMessage = new MailMessage + { + From = new MailAddress(senderEmail, senderName), + Subject = emailSubject, + Body = emailBody, + IsBodyHtml = true + }; + + mailMessage.To.Add(email); + + await smtpClient.SendMailAsync(mailMessage); + + Console.WriteLine($"Email OTP {code} sent to {email}"); + return (true, "Email sent successfully"); + } + } + catch (SmtpException ex) + { + Console.WriteLine($"SMTP Error sending email: {ex.Message}"); + return (false, $"Failed to send email: {ex.Message}"); + } + catch (Exception ex) + { + Console.WriteLine($"Exception sending email: {ex.Message}"); + return (false, $"Exception occurred: {ex.Message}"); + } + } + + + public string GenerateSecretKey(int? length = null) + { + var keyLength = length ?? int.Parse(_config["GoogleAuthenticator:SecretKeyLength"] ?? "32"); + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + var random = new Random(); + var secret = new StringBuilder(keyLength); + + for (int i = 0; i < keyLength; i++) + { + secret.Append(chars[random.Next(chars.Length)]); + } + + return secret.ToString(); + } + + public string GenerateQrCodeData(string userEmail, string secretKey, string? issuer = null) + { + var appIssuer = issuer ?? _config["GoogleAuthenticator:Issuer"] ?? "AuthHex"; + var encodedIssuer = Uri.EscapeDataString(appIssuer); + var encodedEmail = Uri.EscapeDataString(userEmail); + + return $"otpauth://totp/{encodedIssuer}:{encodedEmail}?secret={secretKey}&issuer={encodedIssuer}"; + } + + public string GenerateTotpCode(string secretKey, DateTime? timeStamp = null) + { + var time = timeStamp ?? DateTime.UtcNow; + var unixTime = ((DateTimeOffset)time).ToUnixTimeSeconds(); + var timeWindow = int.Parse(_config["GoogleAuthenticator:TimeWindowSeconds"] ?? "30"); + var timeStep = unixTime / timeWindow; + + var secretBytes = Base32Decode(secretKey); + var timeBytes = BitConverter.GetBytes(timeStep); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(timeBytes); + } + + using (var hmac = new HMACSHA1(secretBytes)) + { + var hash = hmac.ComputeHash(timeBytes); + var offset = hash[hash.Length - 1] & 0x0F; + + var code = ((hash[offset] & 0x7F) << 24) | + ((hash[offset + 1] & 0xFF) << 16) | + ((hash[offset + 2] & 0xFF) << 8) | + (hash[offset + 3] & 0xFF); + + return (code % 1000000).ToString("D6"); + } + } + + public bool ValidateTotpCode(string secretKey, string userCode, int? windowSize = null) + { + if (string.IsNullOrWhiteSpace(secretKey) || string.IsNullOrWhiteSpace(userCode)) + { + return false; + } + + var validationWindow = windowSize ?? int.Parse(_config["GoogleAuthenticator:ValidationWindowSize"] ?? "1"); + var timeWindow = int.Parse(_config["GoogleAuthenticator:TimeWindowSeconds"] ?? "30"); + var currentTime = DateTime.UtcNow; + + for (int i = -validationWindow; i <= validationWindow; i++) + { + var timeToCheck = currentTime.AddSeconds(i * timeWindow); + var generatedCode = GenerateTotpCode(secretKey, timeToCheck); + + if (generatedCode == userCode) + { + return true; + } + } + + return false; + } + + public async Task InitiateTwoFASetup(string userEmail, string? userName = null) + { + try + { + if (string.IsNullOrWhiteSpace(userEmail)) + { + return new ApiResponse + { + StatusCode = 400, + Message = "Email is required" + }; + } + + var secretKey = GenerateSecretKey(); + var issuer = _config["GoogleAuthenticator:Issuer"] ?? "AuthHex"; + var qrCodeData = GenerateQrCodeData(userEmail, secretKey, issuer); + var displayName = string.IsNullOrWhiteSpace(userName) ? userEmail : userName; + + return new ApiResponse + { + StatusCode = 200, + Data = new + { + secretKey, + qrCodeData, + manualEntryKey = FormatSecretForManualEntry(secretKey), + userEmail, + displayName, + issuer, + setupInstructions = new + { + step1 = "Install Google Authenticator app on your mobile device", + step2 = "Scan the QR code or enter the manual key", + step3 = "Enter the 6-digit code from your app to verify setup" + } + }, + Message = "2FA setup initiated. Please verify with your authenticator app to complete setup." + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = ex.Message + }; + } + } + + public async Task CompleteTwoFASetup(string secretKey, string verificationCode, string? userEmail = null) + { + try + { + if (string.IsNullOrWhiteSpace(secretKey)) + { + return new ApiResponse + { + StatusCode = 400, + Message = "Secret key is required" + }; + } + + if (string.IsNullOrWhiteSpace(verificationCode)) + { + return new ApiResponse + { + StatusCode = 400, + Message = "Verification code is required" + }; + } + + // Validate the code to ensure user has successfully set up their authenticator + var isValid = ValidateTotpCode(secretKey, verificationCode); + + if (!isValid) + { + return new ApiResponse + { + StatusCode = 400, + Message = "Invalid verification code. Please check your authenticator app and try again." + }; + } + + return new ApiResponse + { + StatusCode = 200, + Data = new + { + secretKey, + isVerified = true, + verifiedAt = DateTime.UtcNow, + userEmail, + backupCodes = GenerateBackupCodes() // Generate backup codes for recovery + }, + Message = "2FA setup completed successfully. Please save your backup codes in a secure location." + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = ex.Message + }; + } + } + + public async Task VerifyTwoFACode(string secretKey, string code) + { + try + { + if (string.IsNullOrWhiteSpace(secretKey)) + { + return new ApiResponse + { + StatusCode = 400, + Message = "Secret key is required" + }; + } + + if (string.IsNullOrWhiteSpace(code)) + { + return new ApiResponse + { + StatusCode = 400, + Message = "Verification code is required" + }; + } + + var isValid = ValidateTotpCode(secretKey, code); + + return new ApiResponse + { + StatusCode = isValid ? 200 : 400, + Data = new + { + isValid, + timestamp = DateTime.UtcNow, + remainingTime = GetRemainingTimeInCurrentWindow() + }, + Message = isValid ? "Code verified successfully" : "Invalid or expired code" + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = ex.Message + }; + } + } + + public async Task DisableTwoFA(string secretKey, string verificationCode) + { + try + { + if (string.IsNullOrWhiteSpace(secretKey)) + { + return new ApiResponse + { + StatusCode = 400, + Message = "Secret key is required" + }; + } + + if (string.IsNullOrWhiteSpace(verificationCode)) + { + return new ApiResponse + { + StatusCode = 400, + Message = "Verification code is required to disable 2FA" + }; + } + + // Require valid code to disable 2FA for security + var isValid = ValidateTotpCode(secretKey, verificationCode); + + if (!isValid) + { + return new ApiResponse + { + StatusCode = 400, + Message = "Invalid verification code. Cannot disable 2FA without valid code." + }; + } + + return new ApiResponse + { + StatusCode = 200, + Data = new + { + disabled = true, + disabledAt = DateTime.UtcNow + }, + Message = "2FA has been disabled successfully" + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = ex.Message + }; + } + } + + private string[] GenerateBackupCodes(int count = 10) + { + var codes = new string[count]; + var random = new Random(); + + for (int i = 0; i < count; i++) + { + codes[i] = random.Next(100000, 999999).ToString(); + } + + return codes; + } + + private int GetRemainingTimeInCurrentWindow() + { + var timeWindow = int.Parse(_config["GoogleAuthenticator:TimeWindowSeconds"] ?? "30"); + var currentTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var remainingSeconds = timeWindow - (currentTime % timeWindow); + return (int)remainingSeconds; + } + + + private byte[] Base32Decode(string input) + { + if (string.IsNullOrEmpty(input)) + throw new ArgumentException("Input cannot be null or empty"); + + input = input.TrimEnd('=').ToUpper(); + var output = new List(); + var bits = 0; + var bitsCount = 0; + + foreach (char c in input) + { + var value = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".IndexOf(c); + if (value < 0) + throw new ArgumentException($"Invalid character in Base32 string: {c}"); + + bits = (bits << 5) | value; + bitsCount += 5; + + if (bitsCount >= 8) + { + output.Add((byte)(bits >> (bitsCount - 8))); + bitsCount -= 8; + } + } + + return output.ToArray(); + } + + private string FormatSecretForManualEntry(string secret) + { + return string.Join(" ", Enumerable.Range(0, secret.Length / 4) + .Select(i => secret.Substring(i * 4, 4))); + } + + } + +} diff --git a/jwt-keys.generated.txt b/jwt-keys.generated.txt new file mode 100644 index 0000000..847c0d2 --- /dev/null +++ b/jwt-keys.generated.txt @@ -0,0 +1,2 @@ +PRIVATE=1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==AQAB

8eD+LRKe1Rh+mLlSxxxj0UmifzS5dlpjtwlmtbRSjk8/WCiMiDHVUqXv3ga4HM9/hbYVyLrq9BOrYANA+itxCI1mZY5+o3PAzowuHuqrEvMHayu/s9U9uvZx54+d/SJEzLeu6uV9/vDDc8KHBtXvRx4LoGXteI4o7QqwWjIKjeM=

4SSSnceuHQ9oKCBI8vQuivn7sPW3JdfPSUTiqcWX5+ZWUPv1akopsyqdzunDCf9uNjlSd71HW9TEJHfE8F45bJlsuKzBXnh2g1OsrrmLHumOOsBjvrhCxPltNC2wJC9kIPBuiK0c/U3KL1VC6X98rr2i7sKVAv4nYM7dZtprCXc=oXYzthrL9CwZthFf9JI6/6ZL1uZ3N043aiPYcEsjIOQ9wLewt+msITOr32ZU2MaatcNK32gHF09aAwmpAlalabQaOojYeHv4pOhmkTTNZiTdOulFWuJqBrgkaRMxv5x0mMO0/BCd/uTxVADy6dk9lRe1YyknUGZ1Y2bTOiJVcUk=tRBWoXGPQ2u6llqwkEN0kuxMUJqr+lE/MWum5mY0rpl93Y9kZWDTfHXPiDJTWt0D47Ph8M5WbzIDtFhmF/GPORmCaFr3AGTc2u0WOUaa7fdjHTjQfvdtK0B1ZLtBpzg9zIfQPTcL02MWMU7eiy6uNVHpkR8H++ot/Rsgz7Dk2C0=GJGZYaPDiE+QeGE1ZrFF4vU+HmOJxG8/+Mxgi5DzPTOSJ9eVjPcXJWeJdC6P1NewWzWk33wlKQ6WOpm78WfTRWIeh9udI3IFqxaW1qs/0fu3ogrF7C5uAaLEqJlLEXAC+Buj2bpLlQlC/qOIWiYJOmYzyQGALfTmkpcqJmc4bxc=FBGzbq+mIZ6K0E8TMHL3cdDsMBZIRvrmRPT52byod8jamtWcd7j+h8uvSifa4ms5LPcmJdFnF7vyc8fGJImMdoL4vz3O19aANekDkuNXtZ7Ru5PQ+O0Ve/FCuZ/AgbwOGGILZC2x2dljiaahOQXQce1F3A68Juoox65Yye5OqNtOmtx0AHBbcO+rpUrJK3BC86hF1br88MQqudhjccq/0ViVncpdt2YkZ5Ny2yEtm3S0oVJ8u9QQ7sQ8oFnVlwHxGH41z8E7/oQYWSKfcDcVjlNp42jim2q1ToKhS56VraytIadUj9g3gIaSxifyXS2nzC2Enm8tkH2NpAnFfMyOtQ==
+PUBLIC=1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==AQAB