in my app during the first login I'm checking if the user already exists, and if he doesn't I want to redirect him to finish registration to Registration/Register action. I have the following code:
public class OpenIdConnectOptionsPostConfigureOptions
: IPostConfigureOptions<OpenIdConnectOptions>
{
private readonly IHttpClientFactory _httpClientFactory;
public OpenIdConnectOptionsPostConfigureOptions(
IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory ??
throw new ArgumentNullException(nameof(httpClientFactory));
}
public void PostConfigure(string name, OpenIdConnectOptions options)
{
options.Events = new OpenIdConnectEvents()
{
OnTicketReceived = async ticketReceivedContext =>
{
var userId = ticketReceivedContext.Principal.Claims
.FirstOrDefault(c => c.Type == "sub").Value;
var apiClient = _httpClientFactory.CreateClient("BasicAPIClient");
var request = new HttpRequestMessage(
HttpMethod.Head,
$"/api/users/{userId}");
request.SetBearerToken(
ticketReceivedContext.Properties.GetTokenValue("access_token"));
var response = await apiClient.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
if(response.StatusCode == HttpStatusCode.NotFound)
{
var claims = new List<Claim>() { new Claim("UserStatus", "NewUser") };
ticketReceivedContext.Principal.AddIdentity(new ClaimsIdentity(claims));
}
}
};
}
}
I couldn't figure out how to do it with redirect so as a workaround I'm adding a claim
var claims = new List<Claim>() { new Claim("UserStatus", "NewUser") };
which I later check
[Authorize]
public IActionResult Login()
{
if (User.HasClaim("UserStatus", "NewUser"))
{
return RedirectToAction("Register", "Home");
}
else
{
return View();
}
}
Is there a better way to do it?
I think you can try like below:
if (response.StatusCode == HttpStatusCode.NotFound)
{
ticketReceivedContext.Response.Redirect("XXX");
ticketReceivedContext.HandleResponse();
}
Related
I got an error when trying to fetch gRPC API (using C#) to blazor client, at first it worked fine but after adding IdentityServer4 and use CORS for gRPC-Web similar like in the docs. Here's the code relevant to the error.
BackEnd/Startup.cs
namespace BackEnd
{
public class Startup
{
public IWebHostEnvironment Environment { get; }
public IConfiguration Configuration { get; }
private string _clientId = null;
private string _clientSecret = null;
public Startup(IWebHostEnvironment environment, IConfiguration configuration)
{
Environment = environment;
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
// Initialize certificate
var cert = new X509Certificate2(Path.Combine(".", "IdsvCertificate.pfx"), "YouShallNotPass123");
var migrationAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
// The connection strings is in user secret
string connectionString = Configuration["ConnectionStrings:DefaultConnection"];
_clientId = Configuration["OAuth:ClientId"];
_clientSecret = Configuration["OAuth:ClientSecret"];
services.AddControllersWithViews();
services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString));
services.AddIdentity<ApplicationUser, IdentityRole>(options => options.SignIn.RequireConfirmedAccount = true)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddClaimsPrincipalFactory<ClaimsFactory>()
.AddDefaultTokenProviders();
var builder = services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
// see https://identityserver4.readthedocs.io/en/latest/topics/resources.html
options.EmitStaticAudienceClaim = true;
options.UserInteraction = new UserInteractionOptions()
{
LoginUrl = "/Account/Login",
LogoutUrl = "/Account/Logout"
};
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiResources(Config.ApiResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients)
.AddProfileService<ProfileService>()
.AddAspNetIdentity<ApplicationUser>()
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = b => b.UseNpgsql(connectionString,
sql => sql.MigrationsAssembly(migrationAssembly));
})
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b => b.UseNpgsql(connectionString,
sql => sql.MigrationsAssembly(migrationAssembly));
});
// Add signed certificate to identity server
builder.AddSigningCredential(cert);
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
// Enable CORS for gRPC
services.AddCors(o => o.AddPolicy("AllowAll", builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
.WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding");
}));
// Add profile service
services.AddScoped<IProfileService, ProfileService>();
services.AddAuthentication()
.AddGoogle("Google", options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.ClientId = _clientId;
options.ClientSecret = _clientSecret;
options.SaveTokens = true;
options.ClaimActions.MapJsonKey("role", "role");
});
services.AddAuthorization();
services.AddGrpc(options =>
{
options.EnableDetailedErrors = true;
});
}
public void Configure(IApplicationBuilder app)
{
InitializeDatabase(app);
if (Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });
app.UseAuthentication();
app.UseCors("AllowAll");
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapGrpcService<UserService>().RequireCors("AllowAll");
endpoints.MapDefaultControllerRoute().RequireAuthorization();
});
}
// Based on IdentityServer4 document
private void InitializeDatabase(IApplicationBuilder app)
{
using (var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope())
{
serviceScope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.Migrate();
var context = serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
context.Database.Migrate();
if (!context.Clients.Any())
{
foreach (var client in Config.Clients)
{
context.Clients.Add(client.ToEntity());
}
context.SaveChanges();
}
if (!context.IdentityResources.Any())
{
foreach (var resource in Config.IdentityResources)
{
context.IdentityResources.Add(resource.ToEntity());
}
context.SaveChanges();
}
if (!context.ApiScopes.Any())
{
foreach (var resource in Config.ApiScopes)
{
context.ApiScopes.Add(resource.ToEntity());
}
context.SaveChanges();
}
}
}
}
}
BackEnd/Services/UserService.cs
namespace BackEnd
{
[Authorize(Roles="User")]
public class UserService : User.UserBase
{
private readonly ILogger<UserService> _logger;
private readonly ApplicationDbContext _dataContext;
public UserService(ILogger<UserService> logger, ApplicationDbContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
public override async Task<Empty> GetUser(UserInfo request, ServerCallContext context)
{
var response = new Empty();
var userList = new UserResponse();
if (_dataContext.UserDb.Any(x => x.Sub == request.Sub))
{
var newUser = new UserInfo(){ Id = userList.UserList.Count, Sub = request.Sub, Email = request.Email };
_dataContext.UserDb.Add(newUser);
userList.UserList.Add(newUser);
await _dataContext.SaveChangesAsync();
}
else
{
var user = _dataContext.UserDb.Single(u => u.Sub == request.Sub);
userList.UserList.Add(user);
}
return await Task.FromResult(response);
}
public override async Task<ToDoItemList> GetToDoList(UuidParameter request, ServerCallContext context)
{
var todoList = new ToDoItemList();
var userInfo = new UserInfo();
var getTodo = (from data in _dataContext.ToDoDb
where data.Uuid == userInfo.Sub
select data).ToList();
todoList.ToDoList.Add(getTodo);
return await Task.FromResult(todoList);
}
public override async Task<Empty> AddToDo(ToDoStructure request, ServerCallContext context)
{
var todoList = new ToDoItemList();
var userInfo = new UserInfo();
var newTodo = new ToDoStructure()
{
Id = todoList.ToDoList.Count,
Uuid = request.Uuid,
Description = request.Description,
IsCompleted = false
};
todoList.ToDoList.Add(newTodo);
await _dataContext.ToDoDb.AddAsync(newTodo);
await _dataContext.SaveChangesAsync();
return await Task.FromResult(new Empty());
}
public override async Task<Empty> PutToDo(ToDoStructure request, ServerCallContext context)
{
var response = new Empty();
_dataContext.ToDoDb.Update(request);
await _dataContext.SaveChangesAsync();
return await Task.FromResult(response);
}
public override async Task<Empty> DeleteToDo(DeleteToDoParameter request, ServerCallContext context)
{
var item = (from data in _dataContext.ToDoDb
where data.Id == request.Id
select data).First();
_dataContext.ToDoDb.Remove(item);
var result = await _dataContext.SaveChangesAsync();
return await Task.FromResult(new Empty());
}
}
}
FrontEnd/Program.cs
namespace FrontEnd
{
public class Program
{
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.Services.AddScoped(sp => new HttpClient()
{ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// Connect server to client
builder.Services.AddScoped(services =>
{
var baseAddressMessageHandler = services.GetRequiredService<AuthorizationMessageHandler>()
.ConfigureHandler(
authorizedUrls: new[] { "https://localhost:5001" },
scopes: new[] { "todoApi" }
);
baseAddressMessageHandler.InnerHandler = new HttpClientHandler();
var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler());
var channel = GrpcChannel.ForAddress("https://localhost:5000", new GrpcChannelOptions
{
HttpHandler = httpHandler
});
return new User.UserClient(channel);
});
// Add Open-ID Connect authentication
builder.Services.AddOidcAuthentication(options =>
{
builder.Configuration.Bind("Authentication:Google", options.ProviderOptions);
options.ProviderOptions.DefaultScopes.Add("role");
options.UserOptions.RoleClaim = "role"; // Important to get role claim
}).AddAccountClaimsPrincipalFactory<CustomUserFactory>();
builder.Services.AddOptions();
builder.Services.AddAuthorizationCore();
await builder.Build().RunAsync();
}
}
}
FrontEnd/Pages/ToDoList.razor.cs
namespace FrontEnd.Pages
{
public partial class TodoList
{
[Inject]
private User.UserClient UserClient { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; }
[CascadingParameter]
public Task<AuthenticationState> authenticationStateTask { get; set; }
public string Description { get; set; }
public string ToDoDescription { get; set; }
public RepeatedField<ToDoStructure> ServerToDoResponse { get; set; } = new RepeatedField<ToDoStructure>();
protected override async Task OnInitializedAsync()
{
var authState = await authenticationStateTask;
var user = authState.User;
Console.WriteLine($"IsAuthenticated: {user.Identity.IsAuthenticated} | IsUser: {user.IsInRole("User")}");
if (user.Identity.IsAuthenticated && user.IsInRole("User"))
{
await GetUser(); // Error when trying to call this function
}
}
// Fetch usser from server
public async Task GetUser()
{
var authState = await authenticationStateTask;
var user = authState.User;
var userRole = user.IsInRole("User");
var userUuid = user.Claims.FirstOrDefault(c => c.Type == "preferred_username").Value;
var subjectId = user.Claims.FirstOrDefault(c => c.Type == "sub").Value;
var userEmail = user.Claims.FirstOrDefault(c => c.Type == "email").Value;
var request = new UserInfo(){ Sub = subjectId, Email = userEmail };
await UserClient.GetUserAsync(request);
await InvokeAsync(StateHasChanged);
await GetToDoList();
}
// Fetch to-do list from server
private async Task GetToDoList()
{
var authState = await authenticationStateTask;
var user = authState.User;
var userUuid = user.Claims.FirstOrDefault(c => c.Type == "preferred_username").Value;
var request = new UuidParameter(){ Uuid = userUuid };
var response = await UserClient.GetToDoListAsync(request);
ServerToDoResponse = response.ToDoList;
}
// Add to-do list to the server
public async Task AddToDo(KeyboardEventArgs e)
{
var authState = await authenticationStateTask;
var user = authState.User;
var userUuid = user.Claims.FirstOrDefault(c => c.Type == "Sub").Value;
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(Description) ||
e.Key == "NumpadEnter" && !string.IsNullOrWhiteSpace(Description))
{
var request = new ToDoStructure()
{
Uuid = userUuid,
Description = this.Description,
};
await UserClient.AddToDoAsync(request);
await InvokeAsync(StateHasChanged);
await GetToDoList();
}
}
// Update the checkbox state of the to-do list
public async Task PutToDoIsCompleted(int id, string description, bool isCompleted, MouseEventArgs e)
{
if (isCompleted == false && e.Button== 0)
{
isCompleted = true;
}
else if (isCompleted == true && e.Button == 0)
{
isCompleted = false;
}
var request = new ToDoStructure()
{
Id = id,
Description = description,
IsCompleted = isCompleted
};
await UserClient.PutToDoAsync(request);
await GetToDoList();
}
// Edit mode function
private async Task EditToDo(int todoId, string description, bool isCompleted)
{
var authState = await authenticationStateTask;
var user = authState.User;
var userUuid = user.Claims.FirstOrDefault(c => c.Type == "Sub").Value;
// Get the index of the to-do list
int grpcIndex = ServerToDoResponse.IndexOf(new ToDoStructure()
{
Id = todoId,
Uuid = userUuid,
Description = description,
IsCompleted = isCompleted
});
ToDoDescription = ServerToDoResponse[grpcIndex].Description;
// Make text area appear and focus on text area and edit icon dissapear based on the to-do list index
await JSRuntime.InvokeVoidAsync("editMode", "edit-icon", "todo-description", "edit-todo", grpcIndex);
await JSRuntime.InvokeVoidAsync("focusTextArea", todoId.ToString(), ToDoDescription);
}
// Update the to-do description
public async Task PutToDoDescription(int id, string htmlId, string oldDescription, string newDescription, bool isCompleted)
{
var authState = await authenticationStateTask;
var user = authState.User;
var userUuid = user.Claims.FirstOrDefault(c => c.Type == "Sub").Value;
var request = new ToDoStructure()
{
Id = id,
Uuid = userUuid,
Description = newDescription,
};
int grpcIndex = ServerToDoResponse.IndexOf(new ToDoStructure()
{
Id = id,
Description = oldDescription,
IsCompleted = isCompleted
});
// Text area auto resize function
await JSRuntime.InvokeVoidAsync("theRealAutoResize", htmlId);
// Make text area display to none and edit icon appear base on the to-do list index
await JSRuntime.InvokeVoidAsync("initialMode", "edit-icon", "todo-description", "edit-todo", grpcIndex);
await UserClient.PutToDoAsync(request);
await GetToDoList();
}
// Delete to-do
public async Task DeleteToDo(int id)
{
var request = new DeleteToDoParameter(){ Id = id };
await UserClient.DeleteToDoAsync(request);
await GetToDoList();
}
}
}
This is the output of the console
Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
Unhandled exception rendering component: Status(StatusCode="Cancelled", Detail="Bad gRPC response. Invalid content-type value: text/html; charset=utf-8")
Grpc.Core.RpcException: Status(StatusCode="Cancelled", Detail="Bad gRPC response. Invalid content-type value: text/html; charset=utf-8")
at FrontEnd.Pages.TodoList.GetUser() in C:\Users\bryan\source\repos\Productivity_App\frontend\Pages\TodoList.razor.cs:line 50
at FrontEnd.Pages.TodoList.OnInitializedAsync() in C:\Users\bryan\source\repos\Productivity_App\frontend\Pages\TodoList.razor.cs:line 35
at Microsoft.AspNetCore.Components.ComponentBase.RunInitAndSetParametersAsync()
at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle)
This is the output in the terminal when trying to authenticate with IdentityServer4 (the authentication and authorization is working fine though)
[21:11:15 Debug] Grpc.AspNetCore.Web.Internal.GrpcWebMiddleware
Detected gRPC-Web request from content-type 'application/grpc-web'.
[21:11:15 Information] Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler
AuthenticationScheme: Identity.Application was challenged.
[21:11:15 Debug] IdentityServer4.Hosting.CorsPolicyProvider
CORS request made for path: /Account/Login from origin: https://localhost:5001 but was ignored because path was not for an allowed IdentityServer CORS endpoint
You can't do OpenID Connect authentication as part of gRPC, the user must have first authenticated on your web site and you should then have received the access token.
Then you can send the access token with gRPC to the API. If you then get a 401 http status back, then you need to refresh(get a new one) the access token.
To make your life easier and to reduce complexity and your sanity, I recommend that you put IdentityServer in its own service, standalone from the client/api. Otherwise its hard to reason about the system and it will be very hard to debug.
My recommendation is that you have this architecture, in three different services:
gRPC is just a transport, similar to HTTP and in the API, you have this basic architecture (slide taken from one of my training classes):
The JwtBearer will examine the access token to verify who you are and after that the authorization module takes over and check if you are allowed in.
Can not get an error message in HTTP response.
if (!string.IsNullOrWhiteSpace(checkTokenResponse.Error))
{
Logger.LogError(checkTokenResponse.Error);
return AuthenticateResult.Fail(checkTokenResponse.Error);
}
response
I suppose the error message should render into data response field
The same question ASP.NET Core - getting a message back from AuthenticationHandler
Update
code of custom Auth handler
public class AuthHandler : AuthenticationHandler<AuthOptions>
{
private readonly IPrimeApiProxy _primeApiProxy;
private readonly IPrimeProxy _primeProxy;
private readonly int _adminGroupId;
private static readonly string HeaderName = "Authorization";
private static readonly string ApiAuthScheme = "oauth ";
public AuthHandler(
IOptionsMonitor<AuthOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
IPrimeApiProxy primeApiProxy,
IPrimeProxy primeProxy,
ApplicationSettingsProvider settings)
: base(options, logger, encoder, clock)
{
_primeApiProxy = primeApiProxy ?? throw new ArgumentNullException(nameof(primeApiProxy));
_primeProxy = primeProxy ?? throw new ArgumentNullException(nameof(primeProxy));
_adminGroupId = int.Parse((settings as dynamic).AdminGroupId as string);
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
Logger.LogTrace("HandleAuthenticateAsync");
if (!Request.Headers.ContainsKey("Authorization"))
{
return AuthenticateResult.Fail("Missing Authorization Header");
}
try
{
var token = FetchToken(Request);
if (string.IsNullOrWhiteSpace(token.token) && token.userId < 1)
{
Logger.LogError("Invalid token");
return AuthenticateResult.Fail("Invalid token");
}
var checkTokenResponse = await _primeProxy.CheckToken(token.token);
if (!string.IsNullOrWhiteSpace(checkTokenResponse.Error))
{
Logger.LogError(checkTokenResponse.Error);
return AuthenticateResult.Fail(checkTokenResponse.Error);
}
var isValidInt = int.TryParse(checkTokenResponse.UserId, out var userId);
if (!isValidInt)
{
return AuthenticateResult.Fail("User Id is invalid");
}
if (token.userId != userId)
{
return AuthenticateResult.Fail("The token belongs to another user");
}
bool isUserAdminAndCustomGroupMember = false;
if (checkTokenResponse.UserRole == "admin")
{
isUserAdminAndCustomGroupMember = await _primeApiProxy.IsGroupMember(token.token, token.userId, _adminGroupId);
}
var claims = new List<Claim>()
{
new Claim(ClaimTypes.NameIdentifier, token.userId.ToString()),
new Claim(ClaimTypes.Role, isUserAdminAndCustomGroupMember ? "Admin" : "Learner")
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
Logger.LogError(ex, "Auth exception");
return AuthenticateResult.Fail("Invalid Authorization Header");
}
}
private static (string token, int userId) FetchToken(HttpRequest request)
{
string authHeader = request.Headers[HeaderName];
if (authHeader != null && authHeader.StartsWith(ApiAuthScheme, StringComparison.OrdinalIgnoreCase))
{
string token = authHeader.Substring(ApiAuthScheme.Length).Trim();
string[] parts = token.Split(',', StringSplitOptions.RemoveEmptyEntries);
if (int.TryParse(parts[1], out int userId))
{
return (parts[0], userId);
}
}
return (null, 0);
}
}
So, I have a scenario where I have implemented my own JWT authentication scheme and is the default authentication and challenge scheme in my Startup.cs:
services.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(x =>
{
//code ommitted for brevity
})
.AddCookie("cookie")
.AddOpenIdConnect("facbook", async options =>
{
options.ResponseType = "code";
options.SignInScheme = "cookie";
//code ommitted for brevity
});
As you can see above that I have added AddOpenIdConnect and AddCookie for my external authentication. Now my question is that if I have a Redirect ActionMethod like this, how can return the Challenge scheme to point to my external one (facebook):
[HttpGet]
public async Task<IActionResult> Redirect()
{
var result = await HttpContext.AuthenticateAsync();
if (result.Succeeded)
{
return RedirectToAction("Index");
}
return Challenge("facebook");
}
This would also mean that my AuthenticateAsync would not work in this case since the default authentication scheme is pointing to JWT.
How can I add a this to my Challenge request and AuthenticateAsync method?
Thanks
To return the login page for your custom Identity Provider, you need to call the SignInManager.ConfigureExternalAuthenticationProperties() method. This method lets you define the redirectUrl and provider (you called your provider "facebook").
I have written it like this:
[Controller]
[Route("web/v2/[controller]")]
public class AccountController : Controller
{
private IAccountService accountService;
public AccountController(IAccountService accountService)
{
this.accountService = accountService;
}
// GET: web/Account/connect/{provider}
[AllowAnonymous]
[HttpGet("connect/{medium}/{provider}", Name = "web-v2-account-external-connect-challenge")]
public async Task<ActionResult> ExternalLogin([FromRoute]string medium, [FromRoute]string provider)
{
//var redirectUrl = Url.Action(nameof(ExternalLoginCallback), "Account", new { medium, provider });
var redirectUrl = Url.RouteUrl("web-v2-account-external-connect-callback", new { medium, provider });
var properties = await accountService.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return Challenge(properties, provider);
}
// GET: web/Account/connect/{provider}/callback
[HttpGet("connect/{medium}/{provider}/callback", Name = "web-v2-account-external-connect-callback")]
public async Task<ActionResult> ExternalLoginCallback([FromRoute]string medium, [FromRoute]string provider)
{
try
{
var login_result = await accountService.PerfromExternalLogin();
if (login_result.Status)
{
var model = new LoginResultVM
{
Status = true,
Medium = medium,
Platform = login_result.Platform
};
return View(model);
}
else
{
var model = new LoginResultVM
{
Status = false,
Medium = medium,
Platform = login_result.Platform,
Error = login_result.Error,
ErrorDescription = login_result.ErrorDescription
};
return View(model);
}
}
catch (OtherAccountException otherAccountEx)
{
var model = new LoginResultVM
{
Status = false,
Medium = medium,
Platform = provider,
Error = "Could not login",
ErrorDescription = otherAccountEx.Message
};
return View(model);
}
catch (Exception ex)
{
var model = new LoginResultVM
{
Status = false,
Medium = medium,
Platform = provider,
Error = "Could not login",
ErrorDescription = "There was an error with your social login"
};
return View(model);
}
}
}
While my AccountRepository looks like this:
internal interface IAccountRepository
{
...
}
internal class AccountRepository : IAccountRepository
{
private MintPlayerContext mintplayer_context;
private UserManager<Entities.User> user_manager;
private SignInManager<Entities.User> signin_manager;
private JwtIssuerOptions jwtIssuerOptions;
public AccountRepository(UserManager<Entities.User> user_manager, SignInManager<Entities.User> signin_manager, MintPlayerContext mintplayer_context, IOptions<JwtIssuerOptions> jwtIssuerOptions)
{
this.user_manager = user_manager;
this.signin_manager = signin_manager;
this.mintplayer_context = mintplayer_context;
this.jwtIssuerOptions = jwtIssuerOptions.Value;
}
public async Task<Tuple<User, string>> Register(User user, string password)
{
...
}
public async Task<LoginResult> LocalLogin(string email, string password, bool createCookie)
{
...
}
public async Task<IEnumerable<AuthenticationScheme>> GetExternalLoginProviders()
{
var providers = await signin_manager.GetExternalAuthenticationSchemesAsync();
return providers.ToList();
}
public Task<AuthenticationProperties> ConfigureExternalAuthenticationProperties(string provider, string redirectUrl)
{
var properties = signin_manager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return Task.FromResult(properties);
}
public async Task<LoginResult> PerfromExternalLogin()
{
var info = await signin_manager.GetExternalLoginInfoAsync();
if (info == null)
throw new UnauthorizedAccessException();
var user = await user_manager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
if (user == null)
{
string username = info.Principal.FindFirstValue(ClaimTypes.Name);
string email = info.Principal.FindFirstValue(ClaimTypes.Email);
var new_user = new Entities.User
{
UserName = username,
Email = email,
PictureUrl = null
};
var id_result = await user_manager.CreateAsync(new_user);
if (id_result.Succeeded)
{
user = new_user;
}
else
{
// User creation failed, probably because the email address is already present in the database
if (id_result.Errors.Any(e => e.Code == "DuplicateEmail"))
{
var existing = await user_manager.FindByEmailAsync(email);
var existing_logins = await user_manager.GetLoginsAsync(existing);
if (existing_logins.Any())
{
throw new OtherAccountException(existing_logins);
}
else
{
throw new Exception("Could not create account from social profile");
}
}
else
{
throw new Exception("Could not create account from social profile");
}
}
await user_manager.AddLoginAsync(user, new UserLoginInfo(info.LoginProvider, info.ProviderKey, info.ProviderDisplayName));
}
await signin_manager.SignInAsync(user, true);
return new LoginResult
{
Status = true,
Platform = info.LoginProvider,
User = ToDto(user)
};
}
public async Task<IEnumerable<UserLoginInfo>> GetExternalLogins(ClaimsPrincipal userProperty)
{
...
}
public async Task AddExternalLogin(ClaimsPrincipal userProperty)
{
...
}
public async Task RemoveExternalLogin(ClaimsPrincipal userProperty, string provider)
{
...
}
public async Task<User> GetCurrentUser(ClaimsPrincipal userProperty)
{
var user = await user_manager.GetUserAsync(userProperty);
return ToDto(user);
}
public async Task Logout()
{
await signin_manager.SignOutAsync();
}
#region Helper methods
private string CreateToken(Entities.User user)
{
...
}
#endregion
#region Conversion methods
internal static Entities.User ToEntity(User user)
{
...
}
internal static User ToDto(Entities.User user)
{
...
}
#endregion
}
I create project for test authentication in ASP.Net Core Web API with using JWT tokens. I implemented the basic functionality for working with accounts, but I ran into some problems.
UsersController:
[Authorize]
[ApiController]
[Route("[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
private readonly IAuthenticationService _authenticationService;
public UsersController(
IUserService userService,
IAuthenticationService authenticationService)
{
_userService = userService;
_authenticationService = authenticationService;
}
// PUT: users/5
[HttpPut("{id}")]
public async Task<ActionResult> PutUser(int id, [FromBody]UpdateUserModel model)
{
try
{
var user = await _userService.UpdateAsync(model, id);
return Ok();
}
catch(Exception ex)
{
return BadRequest(new { message = ex.Message });
}
}
// POST : users/authenticate
[AllowAnonymous]
[HttpPost("authenticate")]
public async Task<ActionResult<User>> Authenticate([FromBody] AuthenticateUserModel model)
{
var user = await _authenticationService.AuthenticateAsync(model);
if (user == null)
return BadRequest(new { message = "Login or password is incorrect" });
return Ok(user);
}
}
AuthenticationService:
public async Task<User> AuthenticateAsync(AuthenticateUserModel model)
{
var users = await _context.Users.ToListAsync();
var user = users.SingleOrDefault(x => x.Login == model.Login && x.Password == model.Password);
if (user == null)
return null;
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new Claim[]
{
new Claim(ClaimTypes.Name, user.Id.ToString()),
new Claim(ClaimTypes.Role, user.Role)
}),
Expires = DateTime.UtcNow.AddDays(7),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
user.Token = tokenHandler.WriteToken(token);
return user.WithoutPassword();
}
It turns out that after authorization, any user can edit the data of another user if we specify a different id in the client who will send requests. Is it possible to somehow limit the actions thanks to the token or how is it better to do this?
You should't trust the submitted data from the user. you should set UserId in payload data like what you did yourself
new Claim(ClaimTypes.Name, user.Id.ToString()),
and when user edit the data get user id from JWT like this
public int GetCurrentUserId()
{
var claimsIdentity = _contextAccessor.HttpContext.User.Identity as ClaimsIdentity;
var userDataClaim = claimsIdentity?.FindFirst(ClaimTypes.Name);
var userId = userDataClaim?.Value;
return string.IsNullOrWhiteSpace(userId) ? 0 : int.Parse(userId);
}
or
int userId = Convert.ToInt32((User.Identity as ClaimsIdentity).FindFirst(ClaimTypes.Name).Value);
and finally
[HttpPut("PutUser")]
public async Task<ActionResult> PutUser([FromBody]UpdateUserModel model)
{
try
{
int userId = Convert.ToInt32((User.Identity as ClaimsIdentity).FindFirst(ClaimTypes.Name).Value);
var user = await _userService.UpdateAsync(model, userId);
return Ok();
}
catch (Exception ex)
{
return BadRequest(new { message = ex.Message });
}
}
I've already implemented the basic Web API protection via IdentityServer4 based on this.
The demo is based on in-memory data. And most of tutorials are based on EF Core implementation for user data. As I searched there was a IUserService in IdentityServer3 which is now missing in version 4.
builder.AddInMemoryClients(Clients.Get());
builder.AddInMemoryScopes(Scopes.Get());
builder.AddInMemoryUsers(Users.Get());
How can I retrieve my user data from an EF6 store?
In Startup.cs, do this
builder.Services.AddTransient<IResourceOwnerPasswordValidator, ResourceOwnerPasswordValidator>();
builder.Services.AddTransient<IProfileService, ProfileService>();
Here is a sample of ResourceOwnerPasswordValidator and ProfileService
public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator
{
private MyUserManager _myUserService { get; set; }
public ResourceOwnerPasswordValidator()
{
_myUserService = new MyUserManager();
}
public async Task<CustomGrantValidationResult> ValidateAsync(string userName, string password, ValidatedTokenRequest request)
{
var user = await _myUserService.FindByNameAsync(userName);
if (user != null && await _myUserService.CheckPasswordAsync(user, password))
{
return new CustomGrantValidationResult(user.EmailAddress, "password");
}
return new CustomGrantValidationResult("Invalid username or password");
}
}
public class ProfileService : IProfileService
{
MyUserManager _myUserManager;
public ProfileService()
{
_myUserManager = new MyUserManager();
}
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var sub = context.Subject.FindFirst("sub")?.Value;
if (sub != null)
{
var user = await _myUserManager.FindByIdAsync(sub);
var cp = await getClaims(user);
var claims = cp.Claims;
if (context.AllClaimsRequested == false ||
(context.RequestedClaimTypes != null && context.RequestedClaimTypes.Any()))
{
claims = claims.Where(x => context.RequestedClaimTypes.Contains(x.Type)).ToArray().AsEnumerable();
}
context.IssuedClaims = claims;
}
}
public Task IsActiveAsync(IsActiveContext context)
{
return Task.FromResult(0);
}
private async Task<ClaimsPrincipal> getClaims(CustomerSite user)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
var userId = await _myUserManager.GetUserIdAsync(user);
var userName = await _myUserManager.GetUserNameAsync(user);
var id = new ClaimsIdentity();
id.AddClaim(new Claim(JwtClaimTypes.Id, userId));
id.AddClaim(new Claim(JwtClaimTypes.PreferredUserName, userName));
var roles = await _myUserManager.GetRolesAsync(user);
foreach (var roleName in roles)
{
id.AddClaim(new Claim(JwtClaimTypes.Role, roleName));
}
id.AddClaims(await _myUserManager.GetClaimsAsync(user));
return new ClaimsPrincipal(id);
}
}