diff --git a/song_of_the_day/Auth/ConfigurationAD.cs b/song_of_the_day/Auth/ConfigurationAD.cs index 1362fd3..01da3c0 100644 --- a/song_of_the_day/Auth/ConfigurationAD.cs +++ b/song_of_the_day/Auth/ConfigurationAD.cs @@ -10,7 +10,8 @@ public class ConfigurationAD public string LDAPserver { get; set; } = string.Empty; public string LDAPQueryBase { get; set; } = string.Empty; + public string LDAPUserQueryBase { get; set; } = string.Empty; - public string Crew { get; set; } = string.Empty; - public string Managers { get; set; } = string.Empty; + public string CrewGroup { get; set; } = string.Empty; + public string ManagerGroup { get; set; } = string.Empty; } \ No newline at end of file diff --git a/song_of_the_day/Auth/IAuthenticationService.cs b/song_of_the_day/Auth/IAuthenticationService.cs new file mode 100644 index 0000000..5b48778 --- /dev/null +++ b/song_of_the_day/Auth/IAuthenticationService.cs @@ -0,0 +1,4 @@ +public interface IAuthenticationService +{ + bool Authenticate(string userName, string password); +} \ No newline at end of file diff --git a/song_of_the_day/Auth/LdapAuthenticationService.cs b/song_of_the_day/Auth/LdapAuthenticationService.cs new file mode 100644 index 0000000..b97983b --- /dev/null +++ b/song_of_the_day/Auth/LdapAuthenticationService.cs @@ -0,0 +1,15 @@ +public class LdapAuthenticationService : IAuthenticationService +{ + private readonly IConfiguration _configuration; + + public LdapAuthenticationService(IConfiguration configuration) + { + _configuration = configuration; + } + + public bool Authenticate(string username, string password) + { + var ldapInstance = LdapIntegration.Instance; + return ldapInstance.TestLogin(username, password); + } +} \ No newline at end of file diff --git a/song_of_the_day/Auth/PhoneClaimCodeProviderService.cs b/song_of_the_day/Auth/PhoneClaimCodeProviderService.cs new file mode 100644 index 0000000..eff27d5 --- /dev/null +++ b/song_of_the_day/Auth/PhoneClaimCodeProviderService.cs @@ -0,0 +1,58 @@ +public class PhoneClaimCodeProviderService +{ + private Dictionary _phoneClaimCodes; + private Dictionary _phoneClaimNumbers; + + public PhoneClaimCodeProviderService() + { + _phoneClaimCodes = new Dictionary(); + _phoneClaimNumbers = new Dictionary(); + } + + private static Random random = new Random(); + + private static string RandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[random.Next(s.Length)]).ToArray()); + } + + public void GenerateClaimCodeForUserAndNumber(string username, string phoneNumber) + { + var generatedCode = string.Empty; + if (IsCodeGeneratedForUser(username)) + { + generatedCode = _phoneClaimCodes[username]; + } + else + { + generatedCode = RandomString(6); + _phoneClaimCodes[username] = generatedCode; + _phoneClaimNumbers[username] = phoneNumber; + } + + SignalIntegration.Instance.SendMessageToUserAsync("Your phone number validation code is: " + generatedCode, phoneNumber); + } + + public string ValidateClaimCodeForUser(string code, string username) + { + var result = false; + result = _phoneClaimCodes[username] == code; + + if (result) + { + _phoneClaimCodes.Remove(username); + var number = _phoneClaimNumbers[username]; + _phoneClaimNumbers.Remove(username); + return number; + } + + return string.Empty; + } + + public bool IsCodeGeneratedForUser(string username) + { + return _phoneClaimCodes.ContainsKey(username); + } +} \ No newline at end of file diff --git a/song_of_the_day/Config/AppConfiguration.cs b/song_of_the_day/Config/AppConfiguration.cs index 25b181c..f121394 100644 --- a/song_of_the_day/Config/AppConfiguration.cs +++ b/song_of_the_day/Config/AppConfiguration.cs @@ -16,22 +16,21 @@ public class AppConfiguration this.SignalGroupId = Environment.GetEnvironmentVariable("SIGNAL_GROUP_ID") ?? "group.Wmk1UTVQTnh0Sjd6a0xiOGhnTnMzZlNkc2p2Q3c0SXJiQkU2eDlNU0hyTT0="; this.WebUIBaseURL = Environment.GetEnvironmentVariable("WEB_BASE_URL") ?? "https://sotd.disi.dev/"; this.UseBotTag = bool.Parse(Environment.GetEnvironmentVariable("USE_BOT_TAG") ?? "true"); + this.AverageDaysBetweenRequests = int.Parse(Environment.GetEnvironmentVariable("AVERAGE_DAYS_BETWEEN_REQUESTS") ?? "2"); var managersGroupName = Environment.GetEnvironmentVariable("LDAP_ADMINGROUP") ?? "admins"; - var userGroupName = Environment.GetEnvironmentVariable("LDAP_USERGROUP") ?? "everyone"; + var userGroupName = Environment.GetEnvironmentVariable("LDAP_USERGROUP") ?? "everybody"; this.LDAPConfig = new ConfigurationAD() { - Username = Environment.GetEnvironmentVariable("LDAP_BIND") ?? "cn=admin.dc=disi,dc=dev", + Username = Environment.GetEnvironmentVariable("LDAP_BIND") ?? "cn=admin,dc=disi,dc=dev", Password = Environment.GetEnvironmentVariable("LDAP_PASS") ?? "adminPass2022!", Port = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("LDAP_BIND")) ? int.Parse(Environment.GetEnvironmentVariable("LDAP_BIND")) : 389, LDAPserver = Environment.GetEnvironmentVariable("LDAP_URL") ?? "192.168.1.108", LDAPQueryBase = Environment.GetEnvironmentVariable("LDAP_BASE") ?? "dc=disi,dc=dev", - Crew = $"cn={userGroupName},ou=groups,dc=disi,dc=dev", - Managers = $"cn={managersGroupName},ou=groups,dc=disi,dc=dev" + LDAPUserQueryBase = Environment.GetEnvironmentVariable("LDAP_BASE") ?? "ou=people,dc=disi,dc=dev", + CrewGroup = $"cn={userGroupName},ou=groups,dc=disi,dc=dev", + ManagerGroup = $"cn={managersGroupName},ou=groups,dc=disi,dc=dev" }; } - public string Crew { get; set; } = string.Empty; - public string Managers { get; set; } = string.Empty; - public string SignalAPIEndpointUri { get; private set; @@ -87,6 +86,11 @@ public class AppConfiguration get; private set; } + public int AverageDaysBetweenRequests + { + get; private set; + } + public ConfigurationAD LDAPConfig { get; private set; } diff --git a/song_of_the_day/Controllers/AuthController.cs b/song_of_the_day/Controllers/AuthController.cs new file mode 100644 index 0000000..72f0082 --- /dev/null +++ b/song_of_the_day/Controllers/AuthController.cs @@ -0,0 +1,40 @@ + +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication; + +public class AuthController : Controller +{ + [HttpPost] + public async Task Login(string username, string password) + { + var ldapService = HttpContext.RequestServices.GetService(); + if (ldapService.Authenticate(username, password)) + { + var claims = new[] { new Claim(ClaimTypes.Name, username) }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + await HttpContext.SignInAsync(new ClaimsPrincipal(identity)); + return RedirectToSamePageIfPossible(); + } + + ViewBag.Error = "Invalid credentials"; + return RedirectToSamePageIfPossible(); + } + + [HttpPost] + public async Task Logout() + { + await HttpContext.SignOutAsync(); + return RedirectToSamePageIfPossible(); + } + + private IActionResult RedirectToSamePageIfPossible() + { + if (Request.Headers.ContainsKey("Referer")) + { + return Redirect(Request.Headers["Referer"].ToString()); + } + return RedirectToPage("/"); + } +} \ No newline at end of file diff --git a/song_of_the_day/Data/Migrations/20250516161831_UpdateUserModel.Designer.cs b/song_of_the_day/Data/Migrations/20250516161831_UpdateUserModel.Designer.cs new file mode 100644 index 0000000..a694b93 --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250516161831_UpdateUserModel.Designer.cs @@ -0,0 +1,142 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace song_of_the_day.DataMigrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250516161831_UpdateUserModel")] + partial class UpdateUserModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Song", b => + { + b.Property("SongId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SongId")); + + b.Property("Artist") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("SongId"); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("SongSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("SongId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("UserId"); + + b.ToTable("SongSuggestions"); + }); + + modelBuilder.Entity("SuggestionHelper", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("SuggestionHelpers"); + }); + + modelBuilder.Entity("User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AssociationInProgress") + .HasColumnType("boolean"); + + b.Property("IsIntroduced") + .HasColumnType("boolean"); + + b.Property("LdapUserName") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NickName") + .HasColumnType("text"); + + b.Property("SignalMemberId") + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SongSuggestion", b => + { + b.HasOne("Song", "Song") + .WithMany() + .HasForeignKey("SongId"); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Song"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250516161831_UpdateUserModel.cs b/song_of_the_day/Data/Migrations/20250516161831_UpdateUserModel.cs new file mode 100644 index 0000000..0e3ad0c --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250516161831_UpdateUserModel.cs @@ -0,0 +1,265 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace song_of_the_day.DataMigrations +{ + /// + public partial class UpdateUserModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_SongSuggestions_Songs_SongId", + table: "SongSuggestions"); + + migrationBuilder.DropForeignKey( + name: "FK_SongSuggestions_Users_UserId", + table: "SongSuggestions"); + + migrationBuilder.AlterColumn( + name: "SignalMemberId", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "NickName", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AddColumn( + name: "AssociationInProgress", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LdapUserName", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AlterColumn( + name: "Title", + table: "SuggestionHelpers", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Description", + table: "SuggestionHelpers", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "SongSuggestions", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AlterColumn( + name: "SongId", + table: "SongSuggestions", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AlterColumn( + name: "Url", + table: "Songs", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Songs", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Artist", + table: "Songs", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AddForeignKey( + name: "FK_SongSuggestions_Songs_SongId", + table: "SongSuggestions", + column: "SongId", + principalTable: "Songs", + principalColumn: "SongId"); + + migrationBuilder.AddForeignKey( + name: "FK_SongSuggestions_Users_UserId", + table: "SongSuggestions", + column: "UserId", + principalTable: "Users", + principalColumn: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_SongSuggestions_Songs_SongId", + table: "SongSuggestions"); + + migrationBuilder.DropForeignKey( + name: "FK_SongSuggestions_Users_UserId", + table: "SongSuggestions"); + + migrationBuilder.DropColumn( + name: "AssociationInProgress", + table: "Users"); + + migrationBuilder.DropColumn( + name: "LdapUserName", + table: "Users"); + + migrationBuilder.AlterColumn( + name: "SignalMemberId", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "NickName", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Title", + table: "SuggestionHelpers", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Description", + table: "SuggestionHelpers", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "SongSuggestions", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SongId", + table: "SongSuggestions", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Url", + table: "Songs", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Songs", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Artist", + table: "Songs", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_SongSuggestions_Songs_SongId", + table: "SongSuggestions", + column: "SongId", + principalTable: "Songs", + principalColumn: "SongId", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_SongSuggestions_Users_UserId", + table: "SongSuggestions", + column: "UserId", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs b/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs index c61ba65..680993c 100644 --- a/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs +++ b/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs @@ -30,15 +30,12 @@ namespace song_of_the_day.DataMigrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SongId")); b.Property("Artist") - .IsRequired() .HasColumnType("text"); b.Property("Name") - .IsRequired() .HasColumnType("text"); b.Property("Url") - .IsRequired() .HasColumnType("text"); b.HasKey("SongId"); @@ -57,10 +54,10 @@ namespace song_of_the_day.DataMigrations b.Property("Date") .HasColumnType("timestamp with time zone"); - b.Property("SongId") + b.Property("SongId") .HasColumnType("integer"); - b.Property("UserId") + b.Property("UserId") .HasColumnType("integer"); b.HasKey("Id"); @@ -81,11 +78,9 @@ namespace song_of_the_day.DataMigrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Description") - .IsRequired() .HasColumnType("text"); b.Property("Title") - .IsRequired() .HasColumnType("text"); b.HasKey("Id"); @@ -101,19 +96,22 @@ namespace song_of_the_day.DataMigrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + b.Property("AssociationInProgress") + .HasColumnType("boolean"); + b.Property("IsIntroduced") .HasColumnType("boolean"); + b.Property("LdapUserName") + .HasColumnType("text"); + b.Property("Name") - .IsRequired() .HasColumnType("text"); b.Property("NickName") - .IsRequired() .HasColumnType("text"); b.Property("SignalMemberId") - .IsRequired() .HasColumnType("text"); b.HasKey("UserId"); @@ -125,15 +123,11 @@ namespace song_of_the_day.DataMigrations { b.HasOne("Song", "Song") .WithMany() - .HasForeignKey("SongId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .HasForeignKey("SongId"); b.HasOne("User", "User") .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .HasForeignKey("UserId"); b.Navigation("Song"); diff --git a/song_of_the_day/Data/User.cs b/song_of_the_day/Data/User.cs index cf01c7d..ff37817 100644 --- a/song_of_the_day/Data/User.cs +++ b/song_of_the_day/Data/User.cs @@ -7,4 +7,6 @@ public class User public string? Name { get; set; } public string? NickName { get; set; } public bool IsIntroduced { get; set; } + public bool AssociationInProgress { get; set; } + public string? LdapUserName { get; set; } } \ No newline at end of file diff --git a/song_of_the_day/LDAPIntegration/Data/LdapUser.cs b/song_of_the_day/LDAPIntegration/Data/LdapUser.cs new file mode 100644 index 0000000..54c544c --- /dev/null +++ b/song_of_the_day/LDAPIntegration/Data/LdapUser.cs @@ -0,0 +1,7 @@ +public class LdapUser +{ + public string? UserId { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? Email { get; set; } +} \ No newline at end of file diff --git a/song_of_the_day/LDAPIntegration/LdapIntegration.cs b/song_of_the_day/LDAPIntegration/LdapIntegration.cs new file mode 100644 index 0000000..9db34c4 --- /dev/null +++ b/song_of_the_day/LDAPIntegration/LdapIntegration.cs @@ -0,0 +1,124 @@ +using System.Collections; +using System.ComponentModel; +using song_of_the_day; +using System.DirectoryServices.Protocols; +using System.Linq; + +public class LdapIntegration +{ + public static LdapIntegration? Instance; + + private readonly string[] attributesToQuery = new string[] + { + "uid", + "givenName", + "sn", + "mail" + }; + + public LdapIntegration(string uri, int port, string adminBind, string adminPass) + { + this.Uri = uri; + this.Port = port; + this.AdminBind = adminBind; + this.AdminPass = adminPass; + } + + private string Uri { get; set; } + + private int Port { get; set; } + + private string AdminBind { get; set; } + + private string AdminPass { get; set; } + + public bool TestLogin(string username, string password) + { + try + { + var userList = this.SearchInADAsUser( + AppConfiguration.Instance.LDAPConfig.LDAPQueryBase, + $"(uid={username})", + SearchScope.Subtree, + username, + password); + } + catch (LdapException ex) + { + if (ex.Message.Contains("credential is invalid")) + { + return false; + } + throw; + } + return true; + } + + public List SearchInAD( + string targetOU, + string query, + SearchScope scope + ) + { + // search as admin + return this.SearchInADAsUser(targetOU, query, scope, this.AdminBind, this.AdminPass); + } + + public List SearchInADAsUser( + string targetOU, + string query, + SearchScope scope, + string userName, + string userPass + ) + { + // on Windows the authentication type is Negotiate, so there is no need to prepend + // AD user login with domain. On other platforms at the moment only + // Basic authentication is supported + var authType = AuthType.Basic; + + var user = userName.StartsWith("cn=") || userName.StartsWith("uid=") ? userName : "uid=" + userName + "," + AppConfiguration.Instance.LDAPConfig.LDAPUserQueryBase; + + //var connection = new LdapConnection(ldapServer) + var connection = new LdapConnection( + new LdapDirectoryIdentifier(this.Uri, this.Port) + ) + { + AuthType = authType, + Credential = new(user, userPass) + }; + + // the default one is v2 (at least in that version), and it is unknown if v3 + // is actually needed, but at least Synology LDAP works only with v3, + // and since our Exchange doesn't complain, let it be v3 + connection.SessionOptions.ProtocolVersion = 3; + + // this is for connecting via LDAPS (636 port). It should be working, + // according to https://github.com/dotnet/runtime/issues/43890, + // but it doesn't (at least with Synology DSM LDAP), although perhaps + // for a different reason + //connection.SessionOptions.SecureSocketLayer = true; + + connection.Bind(); + + var request = new SearchRequest(targetOU, query, scope, attributesToQuery); + + var response = (System.DirectoryServices.Protocols.SearchResponse)connection.SendRequest(request); + + var userList = new List(); + + foreach(SearchResultEntry result in response.Entries) + { + userList.Add(new LdapUser() { + UserId = result.Attributes["uid"][0].ToString(), + FirstName = result.Attributes["givenName"][0].ToString(), + LastName = result.Attributes["sn"][0].ToString(), + Email = result.Attributes["mail"][0].ToString(), + }); + } + + connection.Dispose(); + + return userList; + } +} \ No newline at end of file diff --git a/song_of_the_day/Pages/Shared/_Layout.cshtml b/song_of_the_day/Pages/Shared/_Layout.cshtml index 90e1c82..07fa8cd 100644 --- a/song_of_the_day/Pages/Shared/_Layout.cshtml +++ b/song_of_the_day/Pages/Shared/_Layout.cshtml @@ -1,4 +1,15 @@ - +@{ + bool DoesUserHaveClaimedPhoneNumber() + { + using (var dci = DataContext.Instance) + { + var user = dci.Users.Where(u => u.LdapUserName == User.Identity.Name); + return user.Any(); + } + } +} + + @@ -26,9 +37,18 @@ + @if (this.User.Identity.IsAuthenticated && !DoesUserHaveClaimedPhoneNumber()) + { + + } +
+ +
diff --git a/song_of_the_day/Pages/Shared/_LoginView.cshtml b/song_of_the_day/Pages/Shared/_LoginView.cshtml new file mode 100644 index 0000000..6f586c8 --- /dev/null +++ b/song_of_the_day/Pages/Shared/_LoginView.cshtml @@ -0,0 +1,36 @@ +@using Microsoft.AspNetCore.Authentication + +
+ @if (!this.User.Identity.IsAuthenticated) + { +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ } + else + { +
+
+ Welcome, @User.Identity.Name! +
+
+ +
+
+ } + +
\ No newline at end of file diff --git a/song_of_the_day/Pages/UnclaimedPhoneNumbers.cshtml b/song_of_the_day/Pages/UnclaimedPhoneNumbers.cshtml new file mode 100644 index 0000000..9a13ce6 --- /dev/null +++ b/song_of_the_day/Pages/UnclaimedPhoneNumbers.cshtml @@ -0,0 +1,38 @@ +@page +@model UnclaimedPhoneNumbersModel +@{ + ViewData["Title"] = "Unclaimed Phone Numbers"; + + var codeService = HttpContext.RequestServices.GetService(); + var codeGenerated = codeService.IsCodeGeneratedForUser(User.Identity.Name); +} + +
+ + + + + + @foreach (var user in @Model.UnclaimedUsers) + { + var phone = user.SignalMemberId; var userId = user.UserId; + + + + + } +
Phone NumberClaim
@phone +
+ + +
+
+ @if(codeGenerated) + { +
+ + + +
+ } +
diff --git a/song_of_the_day/Pages/UnclaimedPhoneNumbers.cshtml.cs b/song_of_the_day/Pages/UnclaimedPhoneNumbers.cshtml.cs new file mode 100644 index 0000000..155c1dd --- /dev/null +++ b/song_of_the_day/Pages/UnclaimedPhoneNumbers.cshtml.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.VisualBasic; + +namespace sotd.Pages; + +public class UnclaimedPhoneNumbersModel : PageModel +{ + private readonly ILogger _logger; + + public UnclaimedPhoneNumbersModel(ILogger logger) + { + _logger = logger; + } + + public int userId { get; set; } + + [BindProperty] + public List UnclaimedUsers { get; set; } + + public void OnGet() + { + using (var dci = DataContext.Instance) + { + this.UnclaimedUsers = dci.Users.Where(u => string.IsNullOrEmpty(u.LdapUserName)).ToList(); + } + } + + public void OnPost(int userIndex) + { + using (var dci = DataContext.Instance) + { + var user = dci.Users.Find(userIndex); + var claimCodeService = HttpContext.RequestServices.GetService(); + claimCodeService.GenerateClaimCodeForUserAndNumber(HttpContext.User.Identity.Name, user.SignalMemberId); + this.UnclaimedUsers = dci.Users.Where(u => string.IsNullOrEmpty(u.LdapUserName)).ToList(); + } + } + + public IActionResult OnPostSubmitCode(string code) + { + var claimCodeService = HttpContext.RequestServices.GetService(); + var validatedNumber = claimCodeService.ValidateClaimCodeForUser(code, HttpContext.User.Identity.Name); + if (!string.IsNullOrEmpty(validatedNumber)) + { + using (var dci = DataContext.Instance) + { + var user = dci.Users.Where(u => u.SignalMemberId == validatedNumber).FirstOrDefault(); + if (user == default(User)) + { + throw new Exception("User with specified phone number not found!"); + } + user.LdapUserName = HttpContext.User.Identity.Name; + dci.SaveChanges(); + } + } + else + { + throw new Exception("Invalid code provided!"); + } + + return RedirectToPage("/"); + } +} diff --git a/song_of_the_day/Program.cs b/song_of_the_day/Program.cs index 414c35f..00118fd 100644 --- a/song_of_the_day/Program.cs +++ b/song_of_the_day/Program.cs @@ -5,11 +5,17 @@ using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using System.DirectoryServices.Protocols; +using System.Runtime.CompilerServices; SignalIntegration.Instance = new SignalIntegration(AppConfiguration.Instance.SignalAPIEndpointUri, int.Parse(AppConfiguration.Instance.SignalAPIEndpointPort), AppConfiguration.Instance.HostPhoneNumber); +LdapIntegration.Instance = new LdapIntegration(AppConfiguration.Instance.LDAPConfig.LDAPserver, + AppConfiguration.Instance.LDAPConfig.Port, + AppConfiguration.Instance.LDAPConfig.Username, + AppConfiguration.Instance.LDAPConfig.Password); + var builder = WebApplication.CreateBuilder(args); Console.WriteLine("Setting up user check timer"); @@ -34,7 +40,9 @@ userCheckTimer.OnOccurence += async (s, ea) => Name = newUserContact.Name, SignalMemberId = memberId, NickName = string.Empty, - IsIntroduced = false + IsIntroduced = false, + LdapUserName = string.Empty, + AssociationInProgress = false, }; dci.Users.Add(newUser); needsSaving = true; @@ -47,7 +55,7 @@ userCheckTimer.OnOccurence += async (s, ea) => } await dci.DisposeAsync(); }; -//userCheckTimer.Start(); +userCheckTimer.Start(); Console.WriteLine("Setting up user intro timer"); var userIntroTimer = new CronTimer("*/1 * * * *", "Europe/Vienna", includingSeconds: false); @@ -69,13 +77,23 @@ userIntroTimer.OnOccurence += async (s, ea) => } await dci.DisposeAsync(); }; -//userIntroTimer.Start(); +userIntroTimer.Start(); Console.WriteLine("Setting up pick of the day timer"); var pickOfTheDayTimer = new CronTimer("0 8 * * *", "Europe/Vienna", includingSeconds: false); pickOfTheDayTimer.OnOccurence += async (s, ea) => { + var rand = new Random(); + var num = rand.NextInt64(); + var mod = num % AppConfiguration.Instance.AverageDaysBetweenRequests; + + if (mod > 0) + { + Console.WriteLine("Skipping pick of the day today!"); + return; + } + var dci = DataContext.Instance; var luckyUser = await dci.Users.ElementAtAsync((new Random()).Next(await dci.Users.CountAsync())); var userName = string.IsNullOrEmpty(luckyUser.NickName) ? luckyUser.Name : luckyUser.NickName; @@ -85,88 +103,57 @@ pickOfTheDayTimer.OnOccurence += async (s, ea) => SignalIntegration.Instance.SendMessageToUserAsync($"Today's (optional) suggestion helper to help you pick a song is:\n\n**{suggestion.Title}**\n\n*{suggestion.Description}*", luckyUser.SignalMemberId); SignalIntegration.Instance.SendMessageToUserAsync($"For now please just share your suggestion with the group - in the future I might ask you to share directly with me or via the website to help me keep track of past suggestions!", luckyUser.SignalMemberId); }; -//pickOfTheDayTimer.Start(); +pickOfTheDayTimer.Start(); -var connection = new LdapConnection(AppConfiguration.Instance.LDAPConfig.LDAPserver) +var startUserAssociationProcess = (User userToAssociate) => { - Credential = new( - AppConfiguration.Instance.LDAPConfig.Username, - AppConfiguration.Instance.LDAPConfig.Password - ) + SignalIntegration.Instance.SendMessageToUserAsync($"Hi, I see you are not associated with any website user yet.", userToAssociate.SignalMemberId); + SignalIntegration.Instance.SendMessageToUserAsync($"If you haven't yet, please navigate to https://users.disi.dev to create a new account.", userToAssociate.SignalMemberId); + SignalIntegration.Instance.SendMessageToUserAsync($"Once you have done so, go to https://sotd.disi.dev, login, navigate to \"Unclaimed Phone Numbers\" and click on the \"Claim\" button to start the claim process.", userToAssociate.SignalMemberId); + SignalIntegration.Instance.SendMessageToUserAsync($"With a future update you will be required to submit songs via your user account - at that point you will be skipped during the selection process if you have not yet claimed your phone number!", userToAssociate.SignalMemberId); }; -var attributesToQuery = new string[] +Console.WriteLine("Setting up LdapAssociation timer"); +var ldapAssociationTimer = new CronTimer("*/10 * * * *", "Europe/Vienna", includingSeconds: false); +ldapAssociationTimer.OnOccurence += async (s, ea) => { - "objectGUID", - "sAMAccountName", - "displayName", - "mail", - "whenCreated" -}; - -SearchResponse SearchInAD( - string ldapServer, - int ldapPort, - string domainForAD, - string username, - string password, - string targetOU, - string query, - SearchScope scope, - params string[] attributeList - ) -{ - // on Windows the authentication type is Negotiate, so there is no need to prepend - // AD user login with domain. On other platforms at the moment only - // Basic authentication is supported - var authType = AuthType.Basic; - - //var connection = new LdapConnection(ldapServer) - var connection = new LdapConnection( - new LdapDirectoryIdentifier(ldapServer, ldapPort) - ) + var dci = DataContext.Instance; + var nonAssociatedUsers = dci.Users.Where(u => string.IsNullOrEmpty(u.LdapUserName) && !u.AssociationInProgress); + var needsSaving = false; + foreach (var user in nonAssociatedUsers) { - AuthType = authType, - Credential = new(username, password) - }; - // the default one is v2 (at least in that version), and it is unknown if v3 - // is actually needed, but at least Synology LDAP works only with v3, - // and since our Exchange doesn't complain, let it be v3 - connection.SessionOptions.ProtocolVersion = 3; + user.AssociationInProgress = true; + + startUserAssociationProcess(user); + user.IsIntroduced = true; + needsSaving = true; + } - // this is for connecting via LDAPS (636 port). It should be working, - // according to https://github.com/dotnet/runtime/issues/43890, - // but it doesn't (at least with Synology DSM LDAP), although perhaps - // for a different reason - //connection.SessionOptions.SecureSocketLayer = true; + if (needsSaving) + { + await dci.SaveChangesAsync(); + } + await dci.DisposeAsync(); +}; +ldapAssociationTimer.Start(); - connection.Bind(); - - var request = new SearchRequest(targetOU, query, scope, attributeList); - - return (SearchResponse)connection.SendRequest(request); -} - -var searchResults = SearchInAD( - AppConfiguration.Instance.LDAPConfig.LDAPserver, - AppConfiguration.Instance.LDAPConfig.Port, - AppConfiguration.Instance.LDAPConfig.Username, - AppConfiguration.Instance.LDAPConfig.Password, +var searchResults = LdapIntegration.Instance.SearchInAD( AppConfiguration.Instance.LDAPConfig.LDAPQueryBase, - new StringBuilder("(&") - .Append("(objectCategory=person)") - .Append("(objectClass=user)") - .Append($"(memberOf={_configurationAD.Crew})") - .Append("(!(userAccountControl:1.2.840.113556.1.4.803:=2))") - .Append(")") - .ToString(), - SearchScope.Subtree, - attributesToQuery + $"(memberOf={AppConfiguration.Instance.LDAPConfig.CrewGroup})", + SearchScope.Subtree ); // Add services to the container. builder.Services.AddRazorPages(); builder.Services.AddOpenApi(); +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/Auth/Login"; + }); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -188,5 +175,15 @@ app.UseAuthorization(); app.MapStaticAssets(); app.MapRazorPages() .WithStaticAssets(); +app.MapControllerRoute( + name: "login", + pattern: "{controller=Auth}/{action=Login}" +); +app.MapControllerRoute( + name: "logout", + pattern: "{controller=Auth}/{action=Logout}" +); +app.MapGet("/debug/routes", (IEnumerable endpointSources) => + string.Join("\n", endpointSources.SelectMany(source => source.Endpoints))); app.Run(); \ No newline at end of file