From 37146568a82aae0822679c76e34b245baba79233 Mon Sep 17 00:00:00 2001 From: vasich Date: Sat, 27 Jul 2024 12:21:46 +0700 Subject: [PATCH] finally implemented oauth 2.0 --- .../Yuna.Website.Server/API/AuthEndpoints.cs | 10 +- .../API/DTO/LoginRequest.cs | 13 ++ .../API/OpenAuthEndpoints.cs | 159 +++++++++++++++++- .../OpenAuthService/IOpenAuthService.cs | 2 +- .../OpenAuthService/OpenAuthService.cs | 5 + Yuna.Website/Yuna.Website.Server/Starter.cs | 15 +- .../src/pages/login/LoginPageService.ts | 21 ++- .../src/pages/login/LoginPageStore.ts | 48 ++++++ .../src/pages/login/types.ts | 9 +- .../yuna.website.client/src/services/Api.ts | 2 +- 10 files changed, 260 insertions(+), 24 deletions(-) create mode 100644 Yuna.Website/Yuna.Website.Server/API/DTO/LoginRequest.cs diff --git a/Yuna.Website/Yuna.Website.Server/API/AuthEndpoints.cs b/Yuna.Website/Yuna.Website.Server/API/AuthEndpoints.cs index 45486cf..b28f596 100644 --- a/Yuna.Website/Yuna.Website.Server/API/AuthEndpoints.cs +++ b/Yuna.Website/Yuna.Website.Server/API/AuthEndpoints.cs @@ -7,6 +7,7 @@ using System.Text.Json.Serialization; using Yuna.Website.Server.Services.TokenService; using Yuna.Website.Server.Model; using Yuna.Website.Server.Services.UserService; +using Yuna.Website.Server.API.DTO; namespace Yuna.Website.Server.API { @@ -27,14 +28,7 @@ namespace Yuna.Website.Server.API } - public class LoginRequest - { - [JsonPropertyName("password")] - public string RawPassword { get; set; } = null!; - - [JsonPropertyName("username")] - public string UserName { get; set; } = null!; - } + public async Task Login(HttpContext context, [FromBody] LoginRequest request, IUserService userService, ITokenService tokenService) { var userFromDb = await userService.GetByUsername(request.UserName); diff --git a/Yuna.Website/Yuna.Website.Server/API/DTO/LoginRequest.cs b/Yuna.Website/Yuna.Website.Server/API/DTO/LoginRequest.cs new file mode 100644 index 0000000..cb32c08 --- /dev/null +++ b/Yuna.Website/Yuna.Website.Server/API/DTO/LoginRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Yuna.Website.Server.API.DTO +{ + public class LoginRequest + { + [JsonPropertyName("password")] + public string RawPassword { get; set; } = null!; + + [JsonPropertyName("username")] + public string UserName { get; set; } = null!; + } +} diff --git a/Yuna.Website/Yuna.Website.Server/API/OpenAuthEndpoints.cs b/Yuna.Website/Yuna.Website.Server/API/OpenAuthEndpoints.cs index a83950d..ad3e0c1 100644 --- a/Yuna.Website/Yuna.Website.Server/API/OpenAuthEndpoints.cs +++ b/Yuna.Website/Yuna.Website.Server/API/OpenAuthEndpoints.cs @@ -1,7 +1,11 @@ using Microsoft.AspNetCore.Mvc; +using System.Text; using System.Text.Json.Serialization; +using Yuna.Website.Server.API.DTO; using Yuna.Website.Server.Infrastructure; using Yuna.Website.Server.Services.OpenAuthService; +using Yuna.Website.Server.Services.UserService; +using Yuna.Website.Server.Storage.Repositories.UserBindings; namespace Yuna.Website.Server.API { @@ -9,11 +13,20 @@ namespace Yuna.Website.Server.API { public void Define(WebApplication app) { - app.MapGet("~/.well-known/openid-configuration", GetConfiguration); + app.MapGet("~/.well-known/openid-configuration", GetConfiguration) + .WithTags("oauth"); - app.MapMethods("/v1.0", ["HEAD"], Ping); + app.MapMethods("/v1.0", ["HEAD"], Ping) + .WithTags("oauth"); - app.MapGet("api/oauth/login", LoginViaOauth); + app.MapGet("api/oauth/login", LoginViaOauth) + .WithTags("oauth"); + + app.MapPost("api/oauth/auth", AuthorizeViaOauth) + .WithTags("oauth"); + + app.MapPost("/api/oauth/token", GetTokenByCode) + .WithTags("oauth"); } public class DiscoveryResponse @@ -141,10 +154,11 @@ namespace Yuna.Website.Server.API } public IResult LoginViaOauth( - [FromQuery] string state, - [FromQuery] string redirect_uri, - [FromQuery]string response_type, - [FromQuery]string client_id, HttpContext context, + [FromQuery] string state, + [FromQuery] string redirect_uri, + [FromQuery] string response_type, + [FromQuery] string client_id, + HttpContext context, IOpenAuthService openAuthService) { @@ -153,7 +167,136 @@ namespace Yuna.Website.Server.API return Results.Unauthorized(); //TODO LOGIN PAGE URL IN SETTINGS - return Results.Redirect($"https://localhost:5173/login?redirect_to={redirect_uri}"); + return Results.Redirect($"https://localhost:5173/login?redirect_to={redirect_uri}&client_id={client_id}&state={state}"); + } + + + public class OauthLoginRequest : LoginRequest + { + [JsonPropertyName("state")] + public string State { get; set; } = null!; + + [JsonPropertyName("redirectUri")] + public string RedirectUri { get; set; } = null!; + } + public async Task AuthorizeViaOauth([FromBody] OauthLoginRequest request, IUserService userService, IOpenAuthService openAuthService, HttpContext context) + { + var user = await userService.GetByUsername(request.UserName); + + if (user is null) return Results.Problem(statusCode: 401, detail:"invalid user input data"); + + var inputHashedPassword = Encrypter.HashPassword(request.RawPassword, user.UserName); + + if (!inputHashedPassword.Equals(user.HashedPassword)) return Results.Problem(statusCode: 401, detail: "invalid login attempt"); + + var binding = openAuthService.CreateBinding(user); + + if(binding is null) return Results.Problem(statusCode: 401, detail: "Unable to create binding"); + + return Results.Ok(request.RedirectUri + $"?code={binding.Code}&state={request.State}"); + } + + + public class OauthTokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = null!; + + [JsonPropertyName("token_type")] + public string TokenType => "Bearer"; + + [JsonPropertyName("expires_in")] + public int ExpiresIn => int.MaxValue; + } + + public class TokenRequest + { + public string Code { get; set; } + public string ClientId { get; set; } = null!; + + public string ClientSecret { get; set; } = null!; + + public string GrantType { get; set; } = null!; + + public string RedirectUri { get; set; } = null!; + } + + public async Task GetTokenByCode( + HttpContext context, + ILogger logger, + IOpenAuthService openAuthService) + { + var request = context.Request; + var sb = new StringBuilder(); + // Записываем метод и URL запроса + sb.AppendLine($"{request.Method} {request.Path}{request.QueryString}"); + + // Записываем заголовки + sb.AppendLine("Headers:"); + foreach (var header in request.Headers) + { + sb.AppendLine($"{header.Key}: {header.Value}"); + } + + // Записываем тело запроса + string bodyStr = ""; + sb.AppendLine("Body:"); + request.EnableBuffering(); // Для того чтобы можно было прочитать тело несколько раз + using (var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true)) + { + var body = await reader.ReadToEndAsync(); + sb.AppendLine(body); + bodyStr = body; + request.Body.Position = 0; // Возвращаем позицию в начале потока + } + + logger.LogInformation(sb.ToString()); + + + + var tokenRequest = ParseQueryStringToTokenRequest(bodyStr); + + + if (tokenRequest.GrantType != "authorization_code") + { + return Results.BadRequest(new { error = "unsupported_grant_type" }); + } + + if(!tokenRequest.ClientId.Equals("vasich_yandex_server")) + { + return Results.BadRequest(new { error = "client id invalid" }); + } + + if(!tokenRequest.ClientSecret.Equals("vasich_molodets_sdelal_oauth")) + { + return Results.BadRequest(new { error = "client secret invalid" }); + } + + var userBinding = openAuthService.GetByCode(tokenRequest.Code); + + if (userBinding is null) return Results.BadRequest(new { error = "Invalid binding" }); + + return Results.Ok(new OauthTokenResponse() + { + AccessToken = userBinding.AccessToken + }); + + } + + private static TokenRequest ParseQueryStringToTokenRequest(string queryString) + { + var queryParams = queryString.Split('&') + .Select(param => param.Split('=')) + .ToDictionary(pair => pair[0], pair => Uri.UnescapeDataString(pair[1])); + + return new TokenRequest + { + Code = queryParams["code"], + ClientId = queryParams["client_id"], + ClientSecret = queryParams["client_secret"], + GrantType = queryParams["grant_type"], + RedirectUri = queryParams["redirect_uri"] + }; } } } diff --git a/Yuna.Website/Yuna.Website.Server/Services/OpenAuthService/IOpenAuthService.cs b/Yuna.Website/Yuna.Website.Server/Services/OpenAuthService/IOpenAuthService.cs index 3d705c6..4af4ed4 100644 --- a/Yuna.Website/Yuna.Website.Server/Services/OpenAuthService/IOpenAuthService.cs +++ b/Yuna.Website/Yuna.Website.Server/Services/OpenAuthService/IOpenAuthService.cs @@ -6,6 +6,6 @@ namespace Yuna.Website.Server.Services.OpenAuthService { public bool ValidateLoginRequest(string responseType, string clientId, string host); public UserBinding CreateBinding(User user); - + public UserBinding? GetByCode(string code); } } diff --git a/Yuna.Website/Yuna.Website.Server/Services/OpenAuthService/OpenAuthService.cs b/Yuna.Website/Yuna.Website.Server/Services/OpenAuthService/OpenAuthService.cs index 1b6a0ed..f2dba29 100644 --- a/Yuna.Website/Yuna.Website.Server/Services/OpenAuthService/OpenAuthService.cs +++ b/Yuna.Website/Yuna.Website.Server/Services/OpenAuthService/OpenAuthService.cs @@ -43,5 +43,10 @@ namespace Yuna.Website.Server.Services.OpenAuthService return result; } + + public UserBinding? GetByCode(string code) + { + return _userBindingsRepository.GetUserBinding(code); + } } } diff --git a/Yuna.Website/Yuna.Website.Server/Starter.cs b/Yuna.Website/Yuna.Website.Server/Starter.cs index 1eb9770..eb1cb86 100644 --- a/Yuna.Website/Yuna.Website.Server/Starter.cs +++ b/Yuna.Website/Yuna.Website.Server/Starter.cs @@ -191,8 +191,8 @@ namespace Yuna.Website.Server builder.Services.AddSwaggerGen(); builder.Services.AddCors(); - + //builder.Services.AddAutoMapper(typeof(ApplicationProfile)); } @@ -201,7 +201,14 @@ namespace Yuna.Website.Server { if (app.Environment.IsDevelopment()) { - app.UseCors(x => x.WithOrigins("https://localhost:5173", "http://localhost:5173").AllowAnyMethod().AllowAnyHeader().AllowCredentials()); + app.UseCors(x => + x.WithOrigins("https://localhost:5173", "http://localhost:5173", "https://192.168.1.2:5227") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + .SetIsOriginAllowed(origin => true) // Разрешить все источники + .WithExposedHeaders("Location")); // Разрешить заголовок Location) + } else @@ -213,12 +220,12 @@ namespace Yuna.Website.Server app.UseDefaultFiles(); app.UseStaticFiles(); - + app.Use(async (context, next) => { var isLoggedIn = context.User?.Identity?.IsAuthenticated ?? false; - if(context.Request.Path.StartsWithSegments("/login") && isLoggedIn) + if (context.Request.Path.StartsWithSegments("/login") && isLoggedIn) { context.Response.Redirect("/"); return; diff --git a/Yuna.Website/yuna.website.client/src/pages/login/LoginPageService.ts b/Yuna.Website/yuna.website.client/src/pages/login/LoginPageService.ts index 0e6a432..07cdf69 100644 --- a/Yuna.Website/yuna.website.client/src/pages/login/LoginPageService.ts +++ b/Yuna.Website/yuna.website.client/src/pages/login/LoginPageService.ts @@ -1,7 +1,10 @@ import { api } from "../../services/Api"; -import { ILoginRequest, ILoginResult } from "./types"; +import { ILoginRequest, ILoginResult, IOauthLoginRequest } from "./types"; +interface IOauthLoginResult extends ILoginResult { + href: string | null; +} export class LoginPageService { public static async login(request: ILoginRequest): Promise { @@ -18,4 +21,20 @@ export class LoginPageService { return { loggedIn: false, isError: true }; } } + + + + public static async loginOauth(request: IOauthLoginRequest): Promise { + try { + const response = await api.post("/oauth/auth", request); + if (response.status === 200) return { loggedIn: true, isError: false, href: response.data }; + + return { loggedIn: false, isError: false, href: null }; + } + + catch (error) { + console.error("Error: ", error); + return { loggedIn: false, isError: true, href: null }; + } + } } diff --git a/Yuna.Website/yuna.website.client/src/pages/login/LoginPageStore.ts b/Yuna.Website/yuna.website.client/src/pages/login/LoginPageStore.ts index e590060..1884d84 100644 --- a/Yuna.Website/yuna.website.client/src/pages/login/LoginPageStore.ts +++ b/Yuna.Website/yuna.website.client/src/pages/login/LoginPageStore.ts @@ -2,6 +2,7 @@ import { makeAutoObservable } from "mobx"; import { LoginPageService } from "./LoginPageService"; import { createStandaloneToast } from "@chakra-ui/react"; import { error } from "../../utils/ToastHelper"; +import { IOauthQueryParams } from "./types"; @@ -11,6 +12,17 @@ export class LoginPageStore { password: string = ""; isLoading: boolean = false; + private getQueryParams(): IOauthQueryParams | null { + const url = window.location.href; + const urlObj = new URL(url); + const params = new URLSearchParams(urlObj.search); + + const redirectUri: string | null = params.get('redirect_to'); + const state: string | null = params.get('state'); + + if (state && redirectUri) return { redirectUri: redirectUri, state: state } + return null; + } setLogin(value: string) { this.login = value; @@ -22,7 +34,43 @@ export class LoginPageStore { } async handleLogin() { + const oauthParams = this.getQueryParams() + + if (oauthParams) await this.loginAsOauth(oauthParams); + else await this.loginSimpleWay() + } + + async loginAsOauth(oauthParams: IOauthQueryParams) { this.isLoading = true; + + const result = await LoginPageService + .loginOauth({ + password: this.password, + username: this.login, + redirectUri: oauthParams.redirectUri, + state: oauthParams.state + }); + this.isLoading = false; + + if (result?.isError) { + error("Ошибка", "Запрос не был выполнен") + return; + } + + if (result && !result.loggedIn) { + error("Неверный логин или пароль", "Проверьте еще раз") + return; + } + + window.location.replace(result.href!); + + } + + + async loginSimpleWay() { + + this.isLoading = true; + const result = await LoginPageService.login({ password: this.password, username: this.login }) this.isLoading = false; diff --git a/Yuna.Website/yuna.website.client/src/pages/login/types.ts b/Yuna.Website/yuna.website.client/src/pages/login/types.ts index 8cb6edf..d4ceff9 100644 --- a/Yuna.Website/yuna.website.client/src/pages/login/types.ts +++ b/Yuna.Website/yuna.website.client/src/pages/login/types.ts @@ -6,4 +6,11 @@ export interface ILoginResult { export interface ILoginRequest { password: string; username: string; -} \ No newline at end of file +} + +export interface IOauthQueryParams { + redirectUri: string; + state: string; +} + +export interface IOauthLoginRequest extends IOauthQueryParams, ILoginRequest { } \ No newline at end of file diff --git a/Yuna.Website/yuna.website.client/src/services/Api.ts b/Yuna.Website/yuna.website.client/src/services/Api.ts index 2dc89f1..c8cf293 100644 --- a/Yuna.Website/yuna.website.client/src/services/Api.ts +++ b/Yuna.Website/yuna.website.client/src/services/Api.ts @@ -4,7 +4,7 @@ export const api = axios.create({ baseURL: "https://192.168.1.2:5227/api/", validateStatus: (status) => status < 500, withCredentials: true, - headers: { 'Accept': 'application/json' } + headers: { 'Content-Type': 'application/json' } }); api.interceptors.response.use(response => {