diff --git a/backend/TRFSAE.MemberPortal.API/Controllers/AuthController.cs b/backend/TRFSAE.MemberPortal.API/Controllers/AuthController.cs new file mode 100644 index 0000000..4314563 --- /dev/null +++ b/backend/TRFSAE.MemberPortal.API/Controllers/AuthController.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Mvc; +using Supabase; +using TRFSAE.MemberPortal.API.Interfaces; +using TRFSAE.MemberPortal.API.Models; + +namespace TRFSAE.MemberPortal.API.Controllers; + +[ApiController] +[Route("api/auth")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + private readonly Client _supabaseClient; + + public AuthController(IAuthService authService, Client supabaseClient) + { + _authService = authService; + _supabaseClient = supabaseClient; + } + + [HttpGet("google")] + public async Task GoogleLogin() + { + var callbackUrl = "http://127.0.0.1:5096/api/auth/callback"; //change to actual auth frontend url when deployed + var verifierBytes = new byte[32]; + System.Security.Cryptography.RandomNumberGenerator.Fill(verifierBytes); + var pkceVerifier = Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlEncode(verifierBytes); + + var challengeBytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(pkceVerifier)); + var pkceChallenge = Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlEncode(challengeBytes); + + var options = new Supabase.Gotrue.SignInOptions + { + RedirectTo = callbackUrl, + QueryParams = new Dictionary + { + { "code_challenge", pkceChallenge }, + { "code_challenge_method", "s256" } + } + }; + + var providerState = await _supabaseClient.Auth.SignIn(Supabase.Gotrue.Constants.Provider.Google, options); + + Response.Cookies.Append("pkce_verifier", pkceVerifier, new CookieOptions + { + HttpOnly = true, + Secure = Request.IsHttps, // Set to true in production + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.AddMinutes(5) + }); + + return Redirect(providerState.Uri.ToString()); + } + + [HttpGet("callback")] + public async Task Callback([FromQuery] string code) + { + if (string.IsNullOrEmpty(code)) return BadRequest("Missing authorization code."); + + if (!Request.Cookies.TryGetValue("pkce_verifier", out var pkceVerifier)) + { + return BadRequest("PKCE verifier missing or expired. Please try logging in again."); + } + + try + { + var session = await _supabaseClient.Auth.ExchangeCodeForSession(pkceVerifier, code); + + Response.Cookies.Append("access_token", session.AccessToken, new CookieOptions + { + HttpOnly = true, + Secure = Request.IsHttps, + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.AddSeconds(session.ExpiresIn) + }); + + Response.Cookies.Delete("pkce_verifier"); + + await _authService.SyncUserToDatabase(session.AccessToken); + + return Redirect("http://127.0.0.1:3000/"); + } + catch (Exception ex) + { + return StatusCode(500, "Authentication failed: " + ex.Message); + } + } + + [HttpGet("me")] + public async Task Me() + { + if (!Request.Cookies.TryGetValue("access_token", out var token)) + { + return Unauthorized(); + } + + if (!_authService.ValidateSupabaseToken(token)) return Unauthorized(); + + var user = await _authService.GetUserFromToken(token); + if (user == null) return NotFound(new { message = "User not found" }); + + return Ok(user); + } + + [HttpPost("logout")] + public async Task Logout() + { + try + { + await _supabaseClient.Auth.SignOut(); + } + catch + { + //ensures that cookie is deleted even when waiting for supabase + } + + Response.Cookies.Delete("access_token"); + return Ok(new { message = "Successfully logged out." }); + } +} \ No newline at end of file diff --git a/backend/TRFSAE.MemberPortal.API/Enums.cs b/backend/TRFSAE.MemberPortal.API/Enums.cs index 6be88ad..cba0d22 100644 --- a/backend/TRFSAE.MemberPortal.API/Enums.cs +++ b/backend/TRFSAE.MemberPortal.API/Enums.cs @@ -57,7 +57,8 @@ public enum Role Admin, SystemLead, SubsystemLead, - Member + Member, + Unverified } [JsonConverter(typeof(StringEnumConverter))] diff --git a/backend/TRFSAE.MemberPortal.API/Interfaces/IAuthService.cs b/backend/TRFSAE.MemberPortal.API/Interfaces/IAuthService.cs new file mode 100644 index 0000000..539db1f --- /dev/null +++ b/backend/TRFSAE.MemberPortal.API/Interfaces/IAuthService.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Supabase.Gotrue; +using TRFSAE.MemberPortal.API.DTOs; +using TRFSAE.MemberPortal.API.Models; + +namespace TRFSAE.MemberPortal.API.Interfaces; + +public interface IAuthService +{ + bool ValidateSupabaseToken(string token); + Task GetUserFromToken(string token); + Task SyncUserToDatabase(string token); + +} \ No newline at end of file diff --git a/backend/TRFSAE.MemberPortal.API/Program.cs b/backend/TRFSAE.MemberPortal.API/Program.cs index a6d7528..8bbcf58 100644 --- a/backend/TRFSAE.MemberPortal.API/Program.cs +++ b/backend/TRFSAE.MemberPortal.API/Program.cs @@ -12,6 +12,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); // builder.Services.AddScoped(); } @@ -36,19 +37,32 @@ return client; }); -// register Supabase client as a singleton for reuse across project -builder.Services.AddScoped(provider => -{ - var options = new SupabaseOptions +builder.Services.AddAuthentication("Bearer") + .AddJwtBearer("Bearer", options => { - AutoConnectRealtime = true, - AutoRefreshToken = true, - }; + var supabaseUrl = builder.Configuration["SupabaseUrl"]; + var jwtSecret = builder.Configuration["SupabaseJwtSecret"]; - var url = builder.Configuration["SupabaseUrl"] ?? throw new InvalidOperationException("Supabase URL is not configured."); - var key = builder.Configuration["SupabaseKey"] ?? throw new InvalidOperationException("Supabase Key is not configured."); - return new Client(url, key, options); -}); + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = $"{supabaseUrl}/auth/v1", + + ValidateAudience = false, + + ValidateLifetime = true, + + ValidateIssuerSigningKey = true, + IssuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey( + System.Text.Encoding.UTF8.GetBytes(jwtSecret!) + ), + + ClockSkew = TimeSpan.Zero + }; + }); + + +builder.Services.AddAuthorization(); // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi @@ -59,7 +73,7 @@ // CORS stuff builder.Services.AddCors(options => { - options.AddPolicy("AllowReactApp", policy => + options.AddPolicy("AllowSvelteApp", policy => { policy.WithOrigins("http://localhost:3000") .AllowAnyHeader() @@ -70,7 +84,7 @@ var app = builder.Build(); -app.UseCors("AllowReactApp"); +app.UseCors("AllowSvelteApp"); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -80,6 +94,7 @@ } // app.UseHttpsRedirection(); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); // using (var scope = app.Services.CreateScope()) diff --git a/backend/TRFSAE.MemberPortal.API/Services/AuthService.cs b/backend/TRFSAE.MemberPortal.API/Services/AuthService.cs new file mode 100644 index 0000000..b0b7830 --- /dev/null +++ b/backend/TRFSAE.MemberPortal.API/Services/AuthService.cs @@ -0,0 +1,72 @@ +using TRFSAE.MemberPortal.API.DTOs; +using TRFSAE.MemberPortal.API.Enums; +using TRFSAE.MemberPortal.API.Interfaces; +using TRFSAE.MemberPortal.API.Models; +using Supabase; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace TRFSAE.MemberPortal.API.Services; + +public class AuthService: IAuthService +{ + private readonly Client _supabaseClient; + private readonly IUserService _userService; + + public AuthService(Client supabaseClient, IUserService userService) + { + _supabaseClient = supabaseClient; + _userService = userService; + } + + public bool ValidateSupabaseToken(string token) + { + if (string.IsNullOrEmpty(token)) return false; + + try + { + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + return jwtToken.ValidTo > DateTime.UtcNow; + } + catch + { + return false; + } + } + + public async Task GetUserFromToken(string token) + { + if (string.IsNullOrEmpty(token)) return null; + + try + { + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + var userIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; + if (userIdClaim == null) return null; + + var userDto = await _userService.GetUserAsync(Guid.Parse(userIdClaim)); + if (userDto == null) return null; + + return new UserModel + { + Id = userDto.Id, + Name = userDto.Name, + Email = userDto.Email, + Subsystem = userDto.Subsystem ?? Subsystem.Frame, + GradYear = userDto.GradYear + }; + } + catch + { + return null; + } + } + + public async Task SyncUserToDatabase(string token) + { + // Sync functionality removed to avoid changes outside auth + await Task.CompletedTask; + } +} \ No newline at end of file diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 3695496..578c961 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -16,7 +16,7 @@