2.2.1: The authentication handler has been updated to skip the token validation if the method from controller is marked with [AllowAnonymous] attribute.
Added authenticated user groups and roles in claims.master
parent
a1841c5727
commit
ce04d2c142
|
@ -0,0 +1,14 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Netmash.Security.Authentication.Tuitio.Abstractions
|
||||||
|
{
|
||||||
|
public interface IUserContextAccessor
|
||||||
|
{
|
||||||
|
bool UserIsLoggedIn { get; }
|
||||||
|
string UserId { get; }
|
||||||
|
string UserName { get; }
|
||||||
|
IEnumerable<(int id, string code)> UserGroups { get; }
|
||||||
|
IEnumerable<(int id, string code)> UserRoles { get; }
|
||||||
|
bool IsAnonymousGuest { get; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
using Tuitio.Wrapper;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Netmash.Security.Authentication.Tuitio.Abstractions;
|
using Netmash.Security.Authentication.Tuitio.Abstractions;
|
||||||
|
using Netmash.Security.Authentication.Tuitio.Services;
|
||||||
using System;
|
using System;
|
||||||
|
using Tuitio.Wrapper;
|
||||||
|
|
||||||
namespace Netmash.Security.Authentication.Tuitio
|
namespace Netmash.Security.Authentication.Tuitio
|
||||||
{
|
{
|
||||||
|
@ -11,6 +12,8 @@ namespace Netmash.Security.Authentication.Tuitio
|
||||||
public static IServiceCollection AddTuitioAuthentication(this IServiceCollection services, string tuitioBaseAddress)
|
public static IServiceCollection AddTuitioAuthentication(this IServiceCollection services, string tuitioBaseAddress)
|
||||||
{
|
{
|
||||||
services.AddTuitioAuthentication(tuitioBaseAddress, new Models.AuthenticationOptions());
|
services.AddTuitioAuthentication(tuitioBaseAddress, new Models.AuthenticationOptions());
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
services.AddScoped<IUserContextAccessor, UserContextAccessor>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,27 @@
|
||||||
namespace Netmash.Security.Authentication.Tuitio.Constants
|
namespace Netmash.Security.Authentication.Tuitio.Constants
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// All claims except "IsAnonymousGuest" are calculated based on the roles the user has.
|
||||||
|
/// IsAnonymousGuest is calculated by the client application through the "AuthenticateAsGuest" option it sent to the authentication handler.
|
||||||
|
/// More precisely, the handler checks the existence and validity of the token, and in case of a negative result, if there is a declared "AuthenticateAsGuest" function, it means that the client application also wants to be contacted before the authentication fails.
|
||||||
|
/// This function receives as a parameter the entire http request and will have full power to decide whether to authorize it or not.
|
||||||
|
/// If the client application decides to authorize the request based on its internal logic, a ticket will be created with three claims (GuestId, GuestName and IsAnonymousGuest: true).
|
||||||
|
/// The id and name of the guest are also given in the configuration by the client application, similar to the "AuthenticateAsGuest" function. Their role is strictly to be used to build the ticket and to be made available to the client application afterwards.
|
||||||
|
/// </summary>
|
||||||
public struct ClaimTypes
|
public struct ClaimTypes
|
||||||
{
|
{
|
||||||
public const string
|
public const string
|
||||||
UserName = "UserName",
|
UserName = "UserName",
|
||||||
FirstName = "FirstName",
|
FirstName = "FirstName",
|
||||||
LastName = "LastName",
|
LastName = "LastName",
|
||||||
IsGuestUser = "IsGuestUser";
|
UserGroups = "UserGroups",
|
||||||
|
UserRoles = "UserRoles",
|
||||||
|
IsAnonymousGuest = "IsAnonymousGuest";
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ClaimIssuer
|
||||||
|
{
|
||||||
|
public const string
|
||||||
|
Tuitio = "Tuitio";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Tuitio.PublishedLanguage.Dto;
|
||||||
|
|
||||||
|
namespace Netmash.Security.Authentication.Tuitio.Extensions
|
||||||
|
{
|
||||||
|
internal static class DataConversions
|
||||||
|
{
|
||||||
|
public static IEnumerable<(int id, string code)> ToTuples(this IEnumerable<RecordIdentifier> recordIdentifiers)
|
||||||
|
{
|
||||||
|
var tuples = recordIdentifiers.Select(z => (z.Id, z.Code));
|
||||||
|
return tuples;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace Netmash.Security.Authentication.Tuitio.Models
|
||||||
|
{
|
||||||
|
internal class ClaimCollection : Dictionary<string, object>
|
||||||
|
{
|
||||||
|
public void TryAddClaim(object value, params string[] keys)
|
||||||
|
{
|
||||||
|
if (keys.Any(z => string.IsNullOrEmpty(z)))
|
||||||
|
throw new Exception($"All keys must have value.");
|
||||||
|
|
||||||
|
if (value == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var isPrimitive = value.GetType().IsPrimitive;
|
||||||
|
if (!isPrimitive)
|
||||||
|
{
|
||||||
|
value = JsonConvert.SerializeObject(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string key in keys)
|
||||||
|
Add(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TryAddRange(Dictionary<string, string> claims, Action<string, string> notifyKeyExistence = null)
|
||||||
|
{
|
||||||
|
if (claims == null || !claims.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var claim in claims)
|
||||||
|
{
|
||||||
|
if (ContainsKey(claim.Key))
|
||||||
|
{
|
||||||
|
notifyKeyExistence?.Invoke(claim.Key, claim.Value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Add(claim.Key, claim.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,11 +13,13 @@
|
||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<Company>Toodle HomeLab</Company>
|
<Company>Toodle HomeLab</Company>
|
||||||
<Copyright>Toodle Netmash</Copyright>
|
<Copyright>Toodle Netmash</Copyright>
|
||||||
<Version>2.2.0</Version>
|
<Version>2.2.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Tuitio.Wrapper" Version="2.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Metadata" Version="6.0.15" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Routing.Abstractions" Version="2.2.0" />
|
||||||
|
<PackageReference Include="Tuitio.Wrapper" Version="2.2.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
2.2.0 release [2023-04-01 22:24]
|
2.2.1 release [2023-04-12 21:54]
|
||||||
|
◾ The authentication handler has been updated to skip the token validation if the method from controller is marked with [AllowAnonymous] attribute.
|
||||||
|
◾ Tuitio nuget packages upgrade
|
||||||
|
◾ Added authenticated user groups and roles in claims.
|
||||||
|
|
||||||
|
2.2.0 release [2023-04-01 22:24]
|
||||||
◾ Tuitio nuget packages upgrade
|
◾ Tuitio nuget packages upgrade
|
||||||
◾ Removed user profile picture url from authentication claims
|
◾ Removed user profile picture url from authentication claims
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Netmash.Security.Authentication.Tuitio.Models;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Tuitio.PublishedLanguage.Dto;
|
||||||
|
using c = Netmash.Security.Authentication.Tuitio.Constants;
|
||||||
|
|
||||||
|
namespace Netmash.Security.Authentication.Tuitio.Services
|
||||||
|
{
|
||||||
|
internal class AuthenticationTicketService
|
||||||
|
{
|
||||||
|
private readonly string _schemeName;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
public AuthenticationTicketService(string schemeName, ILogger logger)
|
||||||
|
{
|
||||||
|
_schemeName = schemeName ?? throw new ArgumentNullException(nameof(schemeName));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationTicket GetGuestAuthenticationTicket(int guestId, string guestName)
|
||||||
|
{
|
||||||
|
var claims = new[] {
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, guestId.ToString()),
|
||||||
|
new Claim(ClaimTypes.Name, guestName),
|
||||||
|
new Claim(c.ClaimTypes.IsAnonymousGuest, bool.TrueString)
|
||||||
|
};
|
||||||
|
|
||||||
|
var ticket = GetAuthenticationTicket(claims);
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationTicket GetAuthenticationTicket(AuthorizationResult authorization)
|
||||||
|
{
|
||||||
|
if (authorization == null)
|
||||||
|
throw new ArgumentNullException(nameof(authorization));
|
||||||
|
|
||||||
|
var claimCollection = new ClaimCollection()
|
||||||
|
{
|
||||||
|
{ ClaimTypes.NameIdentifier, authorization.UserId },
|
||||||
|
{ ClaimTypes.Name, authorization.UserName },
|
||||||
|
{ c.ClaimTypes.UserName, authorization.UserName }
|
||||||
|
};
|
||||||
|
|
||||||
|
claimCollection.TryAddClaim(authorization.FirstName, ClaimTypes.GivenName, c.ClaimTypes.FirstName);
|
||||||
|
claimCollection.TryAddClaim(authorization.LastName, ClaimTypes.Surname, c.ClaimTypes.LastName);
|
||||||
|
claimCollection.TryAddClaim(authorization.Email, ClaimTypes.Email);
|
||||||
|
claimCollection.TryAddClaim(authorization.UserGroups, c.ClaimTypes.UserGroups);
|
||||||
|
claimCollection.TryAddClaim(authorization.UserRoles, c.ClaimTypes.UserRoles);
|
||||||
|
|
||||||
|
claimCollection.TryAddRange(authorization.Claims, (key, value) =>
|
||||||
|
{
|
||||||
|
_logger.LogWarning($"There is already a claim with key {key} in the collection. The combination {key}:{value} will be ignored.");
|
||||||
|
});
|
||||||
|
|
||||||
|
var claims = claimCollection.Select(z => new Claim(z.Key, z.Value.ToString(), z.Value.GetType().Name, c.ClaimIssuer.Tuitio)).ToArray();
|
||||||
|
var ticket = GetAuthenticationTicket(claims);
|
||||||
|
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthenticationTicket GetAuthenticationTicket(Claim[] claims)
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity(claims, _schemeName);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
var ticket = new AuthenticationTicket(principal, _schemeName);
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Netmash.Security.Authentication.Tuitio.Abstractions;
|
||||||
|
using Netmash.Security.Authentication.Tuitio.Extensions;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Tuitio.PublishedLanguage.Dto;
|
||||||
|
using c = Netmash.Security.Authentication.Tuitio.Constants;
|
||||||
|
|
||||||
|
namespace Netmash.Security.Authentication.Tuitio.Services
|
||||||
|
{
|
||||||
|
internal class UserContextAccessor : IUserContextAccessor
|
||||||
|
{
|
||||||
|
private readonly IHttpContextAccessor _httpAccessor;
|
||||||
|
|
||||||
|
public UserContextAccessor(IHttpContextAccessor httpAccessor)
|
||||||
|
{
|
||||||
|
_httpAccessor = httpAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool UserIsLoggedIn => _httpAccessor.HttpContext.User != null;
|
||||||
|
public string UserId => GetUserClaim<string>(ClaimTypes.NameIdentifier, true, true, "User id");
|
||||||
|
public string UserName => GetUserClaim<string>(ClaimTypes.Name, true, true, "User name");
|
||||||
|
public IEnumerable<(int id, string code)> UserGroups
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var groups = GetUserClaim<IEnumerable<RecordIdentifier>>(c.ClaimTypes.UserGroups, false, false);
|
||||||
|
return groups.ToTuples();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<(int id, string code)> UserRoles
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var roles = GetUserClaim<IEnumerable<RecordIdentifier>>(c.ClaimTypes.UserRoles, false, false);
|
||||||
|
return roles.ToTuples();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsAnonymousGuest => GetUserClaim<bool>(c.ClaimTypes.IsAnonymousGuest);
|
||||||
|
|
||||||
|
private T GetUserClaim<T>(string claimType, bool isMandatory = false, bool isPrimitiveType = true, string claimLabel = null)
|
||||||
|
{
|
||||||
|
var claimValue = _httpAccessor.HttpContext.User?.Claims.FirstOrDefault(z => z.Type == claimType)?.Value;
|
||||||
|
if (string.IsNullOrEmpty(claimValue))
|
||||||
|
{
|
||||||
|
if (isMandatory)
|
||||||
|
throw new Exception($"{claimLabel ?? claimType} could not be retrieved from claims.");
|
||||||
|
else
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (isPrimitiveType)
|
||||||
|
return (T)Convert.ChangeType(claimValue, typeof(T));
|
||||||
|
else
|
||||||
|
return JsonConvert.DeserializeObject<T>(claimValue);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new Exception($"Failed to convert claim {claimLabel ?? claimType} to type {typeof(T).Name}.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,12 @@
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Netmash.Security.Authentication.Tuitio.Abstractions;
|
using Netmash.Security.Authentication.Tuitio.Abstractions;
|
||||||
|
using Netmash.Security.Authentication.Tuitio.Services;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Tuitio.PublishedLanguage.Dto;
|
using Tuitio.PublishedLanguage.Dto;
|
||||||
|
@ -29,8 +29,18 @@ namespace Netmash.Security.Authentication.Tuitio
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AuthenticationTicketService TicketService => new(Scheme.Name, _logger);
|
||||||
|
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
|
// if the method is marked with [AllowAnonymous], the handler will skip token validation.
|
||||||
|
var endpointFeature = Context.Features.Get<IEndpointFeature>();
|
||||||
|
var endpoint = endpointFeature?.Endpoint;
|
||||||
|
if (endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
}
|
||||||
|
|
||||||
var token = GetAuthorizationToken();
|
var token = GetAuthorizationToken();
|
||||||
if (token != null)
|
if (token != null)
|
||||||
{
|
{
|
||||||
|
@ -48,14 +58,14 @@ namespace Netmash.Security.Authentication.Tuitio
|
||||||
if (!string.IsNullOrEmpty(authorizationEnvelope.Error))
|
if (!string.IsNullOrEmpty(authorizationEnvelope.Error))
|
||||||
return AuthenticateResult.Fail(authorizationEnvelope.Error);
|
return AuthenticateResult.Fail(authorizationEnvelope.Error);
|
||||||
|
|
||||||
var ticket = GetAuthenticationTicket(authorizationEnvelope.Result);
|
var ticket = TicketService.GetAuthenticationTicket(authorizationEnvelope.Result);
|
||||||
return AuthenticateResult.Success(ticket);
|
return AuthenticateResult.Success(ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
var authenticateAsGuest = _authenticationOptions.AuthenticateAsGuest?.Invoke(Request) ?? false;
|
var authenticateAsGuest = _authenticationOptions.AuthenticateAsGuest?.Invoke(Request) ?? false;
|
||||||
if (authenticateAsGuest)
|
if (authenticateAsGuest)
|
||||||
{
|
{
|
||||||
var guestTicket = GetGuestAuthenticationTicket(_authenticationOptions.GuestUserId, _authenticationOptions.GuestUserName);
|
var guestTicket = TicketService.GetGuestAuthenticationTicket(_authenticationOptions.GuestUserId, _authenticationOptions.GuestUserName);
|
||||||
return AuthenticateResult.Success(guestTicket);
|
return AuthenticateResult.Success(guestTicket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,69 +91,5 @@ namespace Netmash.Security.Authentication.Tuitio
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AuthenticationTicket GetGuestAuthenticationTicket(int guestId, string guestName)
|
|
||||||
{
|
|
||||||
var claims = new[] {
|
|
||||||
new Claim(ClaimTypes.NameIdentifier, guestId.ToString()),
|
|
||||||
new Claim(ClaimTypes.Name, guestName),
|
|
||||||
new Claim(Constants.ClaimTypes.IsGuestUser, bool.TrueString)
|
|
||||||
};
|
|
||||||
|
|
||||||
var ticket = GetAuthenticationTicket(claims);
|
|
||||||
return ticket;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AuthenticationTicket GetAuthenticationTicket(AuthorizationResult authorization)
|
|
||||||
{
|
|
||||||
var claimCollection = new Dictionary<string, string>()
|
|
||||||
{
|
|
||||||
{ ClaimTypes.NameIdentifier, authorization.UserId.ToString() },
|
|
||||||
{ ClaimTypes.Name, authorization.UserName },
|
|
||||||
{ Constants.ClaimTypes.UserName, authorization.UserName }
|
|
||||||
};
|
|
||||||
|
|
||||||
if (authorization.FirstName != null)
|
|
||||||
{
|
|
||||||
claimCollection.Add(ClaimTypes.GivenName, authorization.FirstName);
|
|
||||||
claimCollection.Add(Constants.ClaimTypes.FirstName, authorization.FirstName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authorization.LastName != null)
|
|
||||||
{
|
|
||||||
claimCollection.Add(ClaimTypes.Surname, authorization.FirstName);
|
|
||||||
claimCollection.Add(Constants.ClaimTypes.LastName, authorization.LastName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authorization.Email != null)
|
|
||||||
claimCollection.Add(ClaimTypes.Email, authorization.Email);
|
|
||||||
|
|
||||||
if (authorization.Claims != null && authorization.Claims.Any())
|
|
||||||
{
|
|
||||||
foreach (var claim in authorization.Claims)
|
|
||||||
{
|
|
||||||
if (claimCollection.ContainsKey(claim.Key))
|
|
||||||
{
|
|
||||||
_logger.LogWarning($"There is already a claim with key {claim.Key} in the collection. The combination {claim.Key}:{claim.Value} will be ignored.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
claimCollection.Add(claim.Key, claim.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var claims = claimCollection.Select(z => new Claim(z.Key, z.Value)).ToArray();
|
|
||||||
var ticket = GetAuthenticationTicket(claims);
|
|
||||||
|
|
||||||
return ticket;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AuthenticationTicket GetAuthenticationTicket(Claim[] claims)
|
|
||||||
{
|
|
||||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
|
||||||
var principal = new ClaimsPrincipal(identity);
|
|
||||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
|
||||||
|
|
||||||
return ticket;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue