Compare commits
	
		
			2 Commits
		
	
	
		
			44ff09c119
			...
			70aeb5e0cb
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 70aeb5e0cb | |||
| 90eca43284 | 
| @@ -180,9 +180,10 @@ public class AccountConnection : ModelBase | |||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |     public Guid Id { get; set; } = Guid.NewGuid(); | ||||||
|     [MaxLength(4096)] public string Provider { get; set; } = null!; |     [MaxLength(4096)] public string Provider { get; set; } = null!; | ||||||
|     [MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!; |     [MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!; | ||||||
|  |     [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new(); | ||||||
|      |      | ||||||
|     [MaxLength(4096)] public string? AccessToken { get; set; } |     [JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; } | ||||||
|     [MaxLength(4096)] public string? RefreshToken { get; set; } |     [JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; } | ||||||
|     public Instant? LastUsedAt { get; set; } |     public Instant? LastUsedAt { get; set; } | ||||||
|      |      | ||||||
|     public Guid AccountId { get; set; } |     public Guid AccountId { get; set; } | ||||||
|   | |||||||
| @@ -1,15 +0,0 @@ | |||||||
| using DysonNetwork.Sphere.Account; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// This controller is designed to handle the OAuth callback. |  | ||||||
| /// </summary> |  | ||||||
| [ApiController] |  | ||||||
| [Route("/auth/callback")] |  | ||||||
| public class AuthCallbackController : ControllerBase |  | ||||||
| { |  | ||||||
| } |  | ||||||
| @@ -12,9 +12,8 @@ namespace DysonNetwork.Sphere.Auth.OpenId; | |||||||
| public class ConnectionController( | public class ConnectionController( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     IEnumerable<OidcService> oidcServices, |     IEnumerable<OidcService> oidcServices, | ||||||
|     AccountService accountService,  |     AccountService accounts, | ||||||
|     AuthService authService, |     AuthService auth | ||||||
|     IClock clock |  | ||||||
| ) : ControllerBase | ) : ControllerBase | ||||||
| { | { | ||||||
|     [HttpGet] |     [HttpGet] | ||||||
| @@ -25,7 +24,7 @@ public class ConnectionController( | |||||||
|  |  | ||||||
|         var connections = await db.AccountConnections |         var connections = await db.AccountConnections | ||||||
|             .Where(c => c.AccountId == currentUser.Id) |             .Where(c => c.AccountId == currentUser.Id) | ||||||
|             .Select(c => new { c.Id, c.AccountId, c.Provider, c.ProvidedIdentifier }) |             .Select(c => new { c.Id, c.AccountId, c.Provider, c.ProvidedIdentifier, c.Meta, c.LastUsedAt }) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|         return Ok(connections); |         return Ok(connections); | ||||||
|     } |     } | ||||||
| @@ -63,7 +62,8 @@ public class ConnectionController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var oidcService = oidcServices.FirstOrDefault(s => s.ProviderName.Equals(request.Provider, StringComparison.OrdinalIgnoreCase)); |         var oidcService = oidcServices.FirstOrDefault(s => | ||||||
|  |             s.ProviderName.Equals(request.Provider, StringComparison.OrdinalIgnoreCase)); | ||||||
|         if (oidcService == null) |         if (oidcService == null) | ||||||
|             return BadRequest($"Provider '{request.Provider}' is not supported"); |             return BadRequest($"Provider '{request.Provider}' is not supported"); | ||||||
|  |  | ||||||
| @@ -94,7 +94,8 @@ public class ConnectionController( | |||||||
|     [HttpGet, HttpPost] |     [HttpGet, HttpPost] | ||||||
|     public async Task<IActionResult> HandleCallback([FromRoute] string provider) |     public async Task<IActionResult> HandleCallback([FromRoute] string provider) | ||||||
|     { |     { | ||||||
|         var oidcService = oidcServices.FirstOrDefault(s => s.ProviderName.Equals(provider, StringComparison.OrdinalIgnoreCase)); |         var oidcService = | ||||||
|  |             oidcServices.FirstOrDefault(s => s.ProviderName.Equals(provider, StringComparison.OrdinalIgnoreCase)); | ||||||
|         if (oidcService == null) |         if (oidcService == null) | ||||||
|             return BadRequest($"Provider '{provider}' is not supported."); |             return BadRequest($"Provider '{provider}' is not supported."); | ||||||
|  |  | ||||||
| @@ -106,21 +107,19 @@ public class ConnectionController( | |||||||
|         HttpContext.Session.Remove($"oidc_state_{callbackData.State}"); |         HttpContext.Session.Remove($"oidc_state_{callbackData.State}"); | ||||||
|  |  | ||||||
|         // If sessionState is present, it's a manual connection flow for an existing user. |         // If sessionState is present, it's a manual connection flow for an existing user. | ||||||
|         if (sessionState != null) |         if (sessionState == null) return await HandleLoginOrRegistration(provider, oidcService, callbackData); | ||||||
|         { |  | ||||||
|         var stateParts = sessionState.Split('|'); |         var stateParts = sessionState.Split('|'); | ||||||
|         if (stateParts.Length != 3 || !stateParts[1].Equals(provider, StringComparison.OrdinalIgnoreCase)) |         if (stateParts.Length != 3 || !stateParts[1].Equals(provider, StringComparison.OrdinalIgnoreCase)) | ||||||
|             return BadRequest("State mismatch."); |             return BadRequest("State mismatch."); | ||||||
|  |  | ||||||
|         var accountId = Guid.Parse(stateParts[0]); |         var accountId = Guid.Parse(stateParts[0]); | ||||||
|         return await HandleManualConnection(provider, oidcService, callbackData, accountId); |         return await HandleManualConnection(provider, oidcService, callbackData, accountId); | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Otherwise, it's a login or registration flow. |         // Otherwise, it's a login or registration flow. | ||||||
|         return await HandleLoginOrRegistration(provider, oidcService, callbackData); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async Task<IActionResult> HandleManualConnection(string provider, OidcService oidcService, OidcCallbackData callbackData, Guid accountId) |     private async Task<IActionResult> HandleManualConnection(string provider, OidcService oidcService, | ||||||
|  |         OidcCallbackData callbackData, Guid accountId) | ||||||
|     { |     { | ||||||
|         OidcUserInfo userInfo; |         OidcUserInfo userInfo; | ||||||
|         try |         try | ||||||
| @@ -133,7 +132,9 @@ public class ConnectionController( | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         var existingConnection = await db.AccountConnections |         var existingConnection = await db.AccountConnections | ||||||
|             .FirstOrDefaultAsync(c => c.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase) && c.ProvidedIdentifier == userInfo.UserId); |             .FirstOrDefaultAsync(c => | ||||||
|  |                 c.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase) && | ||||||
|  |                 c.ProvidedIdentifier == userInfo.UserId); | ||||||
|  |  | ||||||
|         if (existingConnection != null && existingConnection.AccountId != accountId) |         if (existingConnection != null && existingConnection.AccountId != accountId) | ||||||
|         { |         { | ||||||
| @@ -141,8 +142,10 @@ public class ConnectionController( | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         var userConnection = await db.AccountConnections |         var userConnection = await db.AccountConnections | ||||||
|             .FirstOrDefaultAsync(c => c.AccountId == accountId && c.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase)); |             .FirstOrDefaultAsync(c => | ||||||
|  |                 c.AccountId == accountId && c.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase)); | ||||||
|  |  | ||||||
|  |         var clock = SystemClock.Instance; | ||||||
|         if (userConnection != null) |         if (userConnection != null) | ||||||
|         { |         { | ||||||
|             userConnection.AccessToken = userInfo.AccessToken; |             userConnection.AccessToken = userInfo.AccessToken; | ||||||
| @@ -158,7 +161,8 @@ public class ConnectionController( | |||||||
|                 ProvidedIdentifier = userInfo.UserId!, |                 ProvidedIdentifier = userInfo.UserId!, | ||||||
|                 AccessToken = userInfo.AccessToken, |                 AccessToken = userInfo.AccessToken, | ||||||
|                 RefreshToken = userInfo.RefreshToken, |                 RefreshToken = userInfo.RefreshToken, | ||||||
|                 LastUsedAt = clock.GetCurrentInstant() |                 LastUsedAt = clock.GetCurrentInstant(), | ||||||
|  |                 Meta = userInfo.ToMetadata(), | ||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -170,7 +174,11 @@ public class ConnectionController( | |||||||
|         return Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl); |         return Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async Task<IActionResult> HandleLoginOrRegistration(string provider, OidcService oidcService, OidcCallbackData callbackData) |     private async Task<IActionResult> HandleLoginOrRegistration( | ||||||
|  |         string provider, | ||||||
|  |         OidcService oidcService, | ||||||
|  |         OidcCallbackData callbackData | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         OidcUserInfo userInfo; |         OidcUserInfo userInfo; | ||||||
|         try |         try | ||||||
| @@ -191,25 +199,17 @@ public class ConnectionController( | |||||||
|             .Include(c => c.Account) |             .Include(c => c.Account) | ||||||
|             .FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId); |             .FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId); | ||||||
|  |  | ||||||
|  |         var clock = SystemClock.Instance; | ||||||
|         if (connection != null) |         if (connection != null) | ||||||
|         { |         { | ||||||
|             // Login existing user |             // Login existing user | ||||||
|             var session = await authService.CreateSessionAsync(connection.Account, clock.GetCurrentInstant()); |             var session = await auth.CreateSessionAsync(connection.Account, clock.GetCurrentInstant()); | ||||||
|             var token = authService.CreateToken(session); |             var token = auth.CreateToken(session); | ||||||
|             return Redirect($"/?token={token}"); |             return Redirect($"/?token={token}"); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var account = await accountService.LookupAccount(userInfo.Email); |  | ||||||
|         if (account == null) |  | ||||||
|         { |  | ||||||
|         // Register new user |         // Register new user | ||||||
|             account = await accountService.CreateAccount(userInfo); |         var account = await accounts.LookupAccount(userInfo.Email) ?? await accounts.CreateAccount(userInfo); | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (account == null) |  | ||||||
|         { |  | ||||||
|             return BadRequest("Unable to create or link account."); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Create connection for new or existing user |         // Create connection for new or existing user | ||||||
|         var newConnection = new AccountConnection |         var newConnection = new AccountConnection | ||||||
| @@ -219,27 +219,29 @@ public class ConnectionController( | |||||||
|             ProvidedIdentifier = userInfo.UserId!, |             ProvidedIdentifier = userInfo.UserId!, | ||||||
|             AccessToken = userInfo.AccessToken, |             AccessToken = userInfo.AccessToken, | ||||||
|             RefreshToken = userInfo.RefreshToken, |             RefreshToken = userInfo.RefreshToken, | ||||||
|             LastUsedAt = clock.GetCurrentInstant() |             LastUsedAt = clock.GetCurrentInstant(), | ||||||
|  |             Meta = userInfo.ToMetadata() | ||||||
|         }; |         }; | ||||||
|         db.AccountConnections.Add(newConnection); |         db.AccountConnections.Add(newConnection); | ||||||
|  |  | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|         var loginSession = await authService.CreateSessionAsync(account, clock.GetCurrentInstant()); |         var loginSession = await auth.CreateSessionAsync(account, clock.GetCurrentInstant()); | ||||||
|         var loginToken = authService.CreateToken(loginSession); |         var loginToken = auth.CreateToken(loginSession); | ||||||
|         return Redirect($"/?token={loginToken}"); |         return Redirect($"/?token={loginToken}"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request) |     private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request) | ||||||
|     { |     { | ||||||
|         var data = new OidcCallbackData(); |         var data = new OidcCallbackData(); | ||||||
|         if (request.Method == "GET") |         switch (request.Method) | ||||||
|         { |         { | ||||||
|  |             case "GET": | ||||||
|                 data.Code = request.Query["code"].FirstOrDefault() ?? ""; |                 data.Code = request.Query["code"].FirstOrDefault() ?? ""; | ||||||
|                 data.IdToken = request.Query["id_token"].FirstOrDefault() ?? ""; |                 data.IdToken = request.Query["id_token"].FirstOrDefault() ?? ""; | ||||||
|                 data.State = request.Query["state"].FirstOrDefault(); |                 data.State = request.Query["state"].FirstOrDefault(); | ||||||
|         } |                 break; | ||||||
|         else if (request.Method == "POST" && request.HasFormContentType) |             case "POST" when request.HasFormContentType: | ||||||
|             { |             { | ||||||
|                 var form = await request.ReadFormAsync(); |                 var form = await request.ReadFormAsync(); | ||||||
|                 data.Code = form["code"].FirstOrDefault() ?? ""; |                 data.Code = form["code"].FirstOrDefault() ?? ""; | ||||||
| @@ -249,10 +251,11 @@ public class ConnectionController( | |||||||
|                 { |                 { | ||||||
|                     data.RawData = form["user"].FirstOrDefault(); |                     data.RawData = form["user"].FirstOrDefault(); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return data; |         return data; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| } | } | ||||||
| @@ -105,7 +105,7 @@ public class OidcController( | |||||||
|         if (string.IsNullOrEmpty(userInfo.Email)) |         if (string.IsNullOrEmpty(userInfo.Email)) | ||||||
|             throw new ArgumentException("Email is required for account creation"); |             throw new ArgumentException("Email is required for account creation"); | ||||||
|  |  | ||||||
|         // Check if account exists by email |         // Check if an account exists by email | ||||||
|         var existingAccount = await accounts.LookupAccount(userInfo.Email); |         var existingAccount = await accounts.LookupAccount(userInfo.Email); | ||||||
|         if (existingAccount != null) |         if (existingAccount != null) | ||||||
|         { |         { | ||||||
| @@ -116,12 +116,28 @@ public class OidcController( | |||||||
|                                           c.ProvidedIdentifier == userInfo.UserId); |                                           c.ProvidedIdentifier == userInfo.UserId); | ||||||
|  |  | ||||||
|             // If no connection exists, create one |             // If no connection exists, create one | ||||||
|             if (existingConnection != null) return existingAccount; |             if (existingConnection != null) | ||||||
|  |             { | ||||||
|  |                 await db.AccountConnections | ||||||
|  |                     .Where(c => c.AccountId == existingAccount.Id && | ||||||
|  |                                 c.Provider == provider && | ||||||
|  |                                 c.ProvidedIdentifier == userInfo.UserId) | ||||||
|  |                     .ExecuteUpdateAsync(s => s | ||||||
|  |                         .SetProperty(c => c.LastUsedAt, SystemClock.Instance.GetCurrentInstant()) | ||||||
|  |                         .SetProperty(c => c.Meta, userInfo.ToMetadata())); | ||||||
|  |  | ||||||
|  |                 return existingAccount; | ||||||
|  |             } | ||||||
|  |  | ||||||
|             var connection = new AccountConnection |             var connection = new AccountConnection | ||||||
|             { |             { | ||||||
|                 AccountId = existingAccount.Id, |                 AccountId = existingAccount.Id, | ||||||
|                 Provider = provider, |                 Provider = provider, | ||||||
|                 ProvidedIdentifier = userInfo.UserId!, |                 ProvidedIdentifier = userInfo.UserId!, | ||||||
|  |                 AccessToken = userInfo.AccessToken, | ||||||
|  |                 RefreshToken = userInfo.RefreshToken, | ||||||
|  |                 LastUsedAt = SystemClock.Instance.GetCurrentInstant(), | ||||||
|  |                 Meta = userInfo.ToMetadata() | ||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             db.AccountConnections.Add(connection); |             db.AccountConnections.Add(connection); | ||||||
| @@ -139,6 +155,10 @@ public class OidcController( | |||||||
|             AccountId = newAccount.Id, |             AccountId = newAccount.Id, | ||||||
|             Provider = provider, |             Provider = provider, | ||||||
|             ProvidedIdentifier = userInfo.UserId!, |             ProvidedIdentifier = userInfo.UserId!, | ||||||
|  |             AccessToken = userInfo.AccessToken, | ||||||
|  |             RefreshToken = userInfo.RefreshToken, | ||||||
|  |             LastUsedAt = SystemClock.Instance.GetCurrentInstant(), | ||||||
|  |             Meta = userInfo.ToMetadata() | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         db.AccountConnections.Add(newConnection); |         db.AccountConnections.Add(newConnection); | ||||||
|   | |||||||
| @@ -16,4 +16,34 @@ public class OidcUserInfo | |||||||
|     public string Provider { get; set; } = ""; |     public string Provider { get; set; } = ""; | ||||||
|     public string? RefreshToken { get; set; } |     public string? RefreshToken { get; set; } | ||||||
|     public string? AccessToken { get; set; } |     public string? AccessToken { get; set; } | ||||||
|  |  | ||||||
|  |     public Dictionary<string, object> ToMetadata() | ||||||
|  |     { | ||||||
|  |         var metadata = new Dictionary<string, object>(); | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(UserId)) | ||||||
|  |             metadata["user_id"] = UserId; | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(Email)) | ||||||
|  |             metadata["email"] = Email; | ||||||
|  |  | ||||||
|  |         metadata["email_verified"] = EmailVerified; | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(FirstName)) | ||||||
|  |             metadata["first_name"] = FirstName; | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(LastName)) | ||||||
|  |             metadata["last_name"] = LastName; | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(DisplayName)) | ||||||
|  |             metadata["display_name"] = DisplayName; | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(PreferredUsername)) | ||||||
|  |             metadata["preferred_username"] = PreferredUsername; | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(ProfilePictureUrl)) | ||||||
|  |             metadata["profile_picture_url"] = ProfilePictureUrl; | ||||||
|  |  | ||||||
|  |         return metadata; | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ | |||||||
|   libharfbuzz0b \ |   libharfbuzz0b \ | ||||||
|   libgif7 \ |   libgif7 \ | ||||||
|   libvips \ |   libvips \ | ||||||
|  |   ffmpeg \ | ||||||
|   && apt-get clean \ |   && apt-get clean \ | ||||||
|   && rm -rf /var/lib/apt/lists/* |   && rm -rf /var/lib/apt/lists/* | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										3626
									
								
								DysonNetwork.Sphere/Migrations/20250615154147_EnrichAccountConnection.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										3626
									
								
								DysonNetwork.Sphere/Migrations/20250615154147_EnrichAccountConnection.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Sphere.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class EnrichAccountConnection : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AddColumn<Dictionary<string, object>>( | ||||||
|  |                 name: "meta", | ||||||
|  |                 table: "account_connections", | ||||||
|  |                 type: "jsonb", | ||||||
|  |                 nullable: true); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropColumn( | ||||||
|  |                 name: "meta", | ||||||
|  |                 table: "account_connections"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -172,6 +172,10 @@ namespace DysonNetwork.Sphere.Migrations | |||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("last_used_at"); |                         .HasColumnName("last_used_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("Meta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("meta"); | ||||||
|  |  | ||||||
|                     b.Property<string>("ProvidedIdentifier") |                     b.Property<string>("ProvidedIdentifier") | ||||||
|                         .IsRequired() |                         .IsRequired() | ||||||
|                         .HasMaxLength(8192) |                         .HasMaxLength(8192) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user