diff --git a/JOBot.Backend/Controllers/HeadHunterHookController.cs b/JOBot.Backend/Controllers/HeadHunterHookController.cs new file mode 100644 index 0000000..27e9310 --- /dev/null +++ b/JOBot.Backend/Controllers/HeadHunterHookController.cs @@ -0,0 +1,23 @@ +using JOBot.Backend.Services; +using Microsoft.AspNetCore.Mvc; + +namespace JOBot.Backend.Controllers; + +[ApiController] +[Route("auth")] +public class HeadHunterHookController(HeadHunterService hhService) + : ControllerBase +{ + + [HttpGet] + public async Task Get(int userId, string? error, string authorizationCode) + { + var res = await hhService.AuthUser(userId, authorizationCode, error); + return res switch + { + HeadHunterService.Status.Success => Ok("Авторизация завершена успешно. Вернитесь в Telegram для продолжения."), + HeadHunterService.Status.UserNotFoundError => NotFound("Пользователь не найден."), + _ => BadRequest("Авторизация завершена с ошибкой. Вернитесь в Telegram для продолжения.") //TODO: Add resource + }; + } +} \ No newline at end of file diff --git a/JOBot.Backend/DTOs/HeadHunterHook/HeadHunterTokenResponseDto.cs b/JOBot.Backend/DTOs/HeadHunterHook/HeadHunterTokenResponseDto.cs new file mode 100644 index 0000000..179cfb3 --- /dev/null +++ b/JOBot.Backend/DTOs/HeadHunterHook/HeadHunterTokenResponseDto.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace JOBot.Backend.DTOs.HeadHunterHook; + +public record HeadHunterTokenResponseDto( + [property:JsonPropertyName("access_token")] + string AccessToken, + [property:JsonPropertyName("expires_in")] + int ExpiresIn, + [property:JsonPropertyName("refresh_token")] + string RefreshToken, + [property:JsonPropertyName("token_type")] + string TokenType + ); \ No newline at end of file diff --git a/JOBot.Backend/Infrastructure/Config/HeadHunterConfig.cs b/JOBot.Backend/Infrastructure/Config/HeadHunterConfig.cs new file mode 100644 index 0000000..e4c1d15 --- /dev/null +++ b/JOBot.Backend/Infrastructure/Config/HeadHunterConfig.cs @@ -0,0 +1,18 @@ +namespace JOBot.Backend.Infrastructure.Config; + +public class HeadHunterConfig +{ + public const string SectionName = "HeadHunter"; + public required HeadHunterLinksConfig Links { get; init; } + public required string ClientId { get; init; } + public required string Secret { get; init; } + + public class HeadHunterLinksConfig + { + public required string AuthLink { get; init; } + public required string HookDomain { get; init; } + public required string HookRoute { get; init; } + public required string HeadHunterApiDomain { get; init; } + public required string HeadHunterTokenRoute { get; init; } + } +} \ No newline at end of file diff --git a/JOBot.Backend/JOBot.Backend.csproj b/JOBot.Backend/JOBot.Backend.csproj index 2c4b3c8..1f9a3c1 100644 --- a/JOBot.Backend/JOBot.Backend.csproj +++ b/JOBot.Backend/JOBot.Backend.csproj @@ -14,6 +14,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/JOBot.Backend/Services/HeadHunterService.cs b/JOBot.Backend/Services/HeadHunterService.cs new file mode 100644 index 0000000..80b3283 --- /dev/null +++ b/JOBot.Backend/Services/HeadHunterService.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using JOBot.Backend.DAL.Context; +using JOBot.Backend.DTOs.HeadHunterHook; +using JOBot.Backend.Infrastructure.Config; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace JOBot.Backend.Services; + +public class HeadHunterService(ILogger logger, IOptions config, AppDbContext dbContext) +{ + private readonly HeadHunterConfig _config = config.Value; + + /// + /// Generate HeadHunter oauth authorization link + /// + /// Telegram UserId + /// Link for auth + public string GenerateAuthLink(int userId) + { + var redirectUri = new UriBuilder(_config.Links.HookDomain) + { + Port = -1, + Scheme = "https", + Path = _config.Links.HookRoute, + Query = $"?userId={userId}" + }.ToString(); + + return string.Format(_config.Links.AuthLink, [_config.ClientId, userId, redirectUri]); + } + + public async Task AuthUser(int userId, string authorizationCode, string? error) + { + logger.LogInformation($"Authorization for user {userId} in process..."); + + if (!string.IsNullOrEmpty(error)) + { + logger.LogWarning($"User {userId} auth completed with error {error}"); + return Status.UserAuthRejectedError; + } + + using var client = new HttpClient(); + var form = new Dictionary + { + { "client-id", _config.ClientId }, + { "client_secret", _config.Secret }, + { "code", authorizationCode }, + { "grant_type", "authorization_code" } + }; + client.BaseAddress = new UriBuilder(_config.Links.HeadHunterApiDomain) + { + Port = -1, + Scheme = "https" + }.Uri; + client.DefaultRequestHeaders.UserAgent.ParseAdd("Jobot BackEnd Service"); + + using var res = await client.SendAsync( + new HttpRequestMessage( + HttpMethod.Post, + _config.Links.HeadHunterTokenRoute) + { + Content = new FormUrlEncodedContent(form) + }); + + if (!res.IsSuccessStatusCode) + { + logger.LogWarning($"Response of HttpRequest ${_config.Links.HeadHunterApiDomain} " + + $"${_config.Links.HeadHunterTokenRoute} has unsuccessful status code {res.StatusCode}"); + return Status.HeadHunterAuthRejectedError; + } + + var responseDto = JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync()); + + if (responseDto == null) + { + logger.LogWarning($"User {userId} auth completed with error " + + $"{nameof(Status.HeadHunterResponseDeserializationFailedError)}"); + return Status.HeadHunterResponseDeserializationFailedError; + } + + var user = await dbContext.Users.FirstOrDefaultAsync(x => x.UserId == userId); + + if (user == null) + { + logger.LogWarning($"User {userId} search completed with error {nameof(Status.UserNotFoundError)}"); + return Status.UserNotFoundError; + } + + user.AccessToken = responseDto.AccessToken; + user.RefreshToken = responseDto.RefreshToken; + + await dbContext.SaveChangesAsync(); + + logger.LogInformation($"User {userId} auth completed!"); + return Status.Success; + } + + public enum Status + { + UserAuthRejectedError, + HeadHunterAuthRejectedError, + UserNotFoundError, + HeadHunterResponseDeserializationFailedError, + Success + } +} \ No newline at end of file diff --git a/JOBot.Backend/Startup.cs b/JOBot.Backend/Startup.cs index 0186b3b..1295739 100644 --- a/JOBot.Backend/Startup.cs +++ b/JOBot.Backend/Startup.cs @@ -1,30 +1,34 @@ using JOBot.Backend.DAL.Context; +using JOBot.Backend.Infrastructure.Config; +using JOBot.Backend.Services; using JOBot.Backend.Services.gRPC; using Microsoft.EntityFrameworkCore; namespace JOBot.Backend; -public class Startup +public class Startup(IConfiguration configuration) { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } + private IConfiguration Configuration { get; } = configuration; public void ConfigureServices(IServiceCollection services) { services.AddGrpc(); services.AddGrpcReflection(); + services.AddControllers(); + services.AddLogging(); services.AddDbContext(options => options.UseNpgsql(Configuration.GetConnectionString("PostgreSQL"))); + + services.Configure(Configuration.GetSection(HeadHunterConfig.SectionName)); + + services.AddScoped(); } public void Configure(WebApplication app, IWebHostEnvironment env) { app.MapGrpcReflectionService().AllowAnonymous(); app.MapGrpcService(); + app.MapControllers(); } } \ No newline at end of file diff --git a/JOBot.Backend/appsettings.json b/JOBot.Backend/appsettings.json index 6bb04e6..6af15be 100644 --- a/JOBot.Backend/appsettings.json +++ b/JOBot.Backend/appsettings.json @@ -9,6 +9,17 @@ "ConnectionStrings": { "PostgreSQL": "Host=postgres;Port=5432;Database=jobot;Username=postgres;Password=LocalDbPass" }, + "HeadHunter": { + "Links": { + "AuthLink": "https://hh.ru/oauth/authorize?response_type=code&client_id={client_id}&redirect_uri={redirect_uri}", + "HookDomain": "jobot.lisoveliy.su", + "HookRoute": "/auth", + "HeadHunterApiDomain": "api.hh.ru", + "HeadHunterTokenRoute": "/token" + }, + "ClientId": "", + "Secret": "" + }, "Kestrel": { "Endpoints": { "gRPC": {