| | | 1 | | using System.Security.Claims; |
| | | 2 | | using Syki.Back.Domain.Identity; |
| | | 3 | | using Syki.Back.Domain.Institutions; |
| | | 4 | | using Syki.Back.Features.Cross.SignIn; |
| | | 5 | | using Microsoft.AspNetCore.Authentication; |
| | | 6 | | using Microsoft.AspNetCore.Authentication.OAuth; |
| | | 7 | | |
| | | 8 | | namespace Syki.Back.Auth.Schemes; |
| | | 9 | | |
| | | 10 | | public static class SocialLoginScheme |
| | | 11 | | { |
| | | 12 | | public const string GoogleScheme = "SocialLogin_Google"; |
| | | 13 | | |
| | | 14 | | public static AuthenticationBuilder AddSocialLoginSchemes( |
| | | 15 | | this AuthenticationBuilder builder, |
| | | 16 | | IConfiguration configuration) |
| | | 17 | | { |
| | 2 | 18 | | var settings = configuration.SocialLogin; |
| | | 19 | | |
| | 2 | 20 | | builder.AddGoogle(GoogleScheme, options => |
| | 2 | 21 | | { |
| | 2 | 22 | | options.ClientId = settings.Google.ClientId; |
| | 2 | 23 | | options.ClientSecret = settings.Google.ClientSecret; |
| | 2 | 24 | | options.SignInScheme = SocialTempScheme.Name; |
| | 2 | 25 | | options.CallbackPath = "/identity/social-login/callback/google"; |
| | 2 | 26 | | options.SaveTokens = false; |
| | 2 | 27 | | options.CorrelationCookie.SameSite = SameSiteMode.Lax; |
| | 2 | 28 | | options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; |
| | 2 | 29 | | |
| | 2 | 30 | | options.Scope.Clear(); |
| | 2 | 31 | | options.Scope.Add("openid"); |
| | 2 | 32 | | options.Scope.Add("email"); |
| | 2 | 33 | | options.Scope.Add("profile"); |
| | 2 | 34 | | |
| | 2 | 35 | | options.ClaimActions.MapJsonKey("email_verified", "email_verified"); |
| | 2 | 36 | | |
| | 2 | 37 | | options.OverrideWith(settings.Google); |
| | 2 | 38 | | |
| | 2 | 39 | | options.Events = new OAuthEvents |
| | 2 | 40 | | { |
| | 2 | 41 | | OnRemoteFailure = HandleRemoteFailure, |
| | 2 | 42 | | OnTicketReceived = HandleTicketReceived, |
| | 2 | 43 | | OnRedirectToAuthorizationEndpoint = HandleRedirectToAuthorizationEndpoint, |
| | 2 | 44 | | }; |
| | 4 | 45 | | }); |
| | | 46 | | |
| | 2 | 47 | | return builder; |
| | | 48 | | } |
| | | 49 | | |
| | | 50 | | private static async Task HandleRemoteFailure(RemoteFailureContext context) |
| | | 51 | | { |
| | 0 | 52 | | var frontend = context.HttpContext.RequestServices.GetRequiredService<FrontendSettings>(); |
| | 0 | 53 | | context.Response.Redirect($"{frontend.Url}?social_login_error={nameof(SocialLoginFailed)}"); |
| | 0 | 54 | | context.HandleResponse(); |
| | 0 | 55 | | } |
| | | 56 | | |
| | | 57 | | private static Task HandleRedirectToAuthorizationEndpoint(RedirectContext<OAuthOptions> context) |
| | | 58 | | { |
| | 0 | 59 | | if (context.Properties.Items.TryGetValue("login_hint", out var loginHint) && loginHint != null) |
| | | 60 | | { |
| | 0 | 61 | | context.RedirectUri += $"&login_hint={Uri.EscapeDataString(loginHint)}"; |
| | | 62 | | } |
| | 0 | 63 | | context.Response.Redirect(context.RedirectUri); |
| | 0 | 64 | | return Task.CompletedTask; |
| | | 65 | | } |
| | | 66 | | |
| | | 67 | | private static async Task HandleTicketReceived(TicketReceivedContext context) |
| | | 68 | | { |
| | 0 | 69 | | var services = context.HttpContext.RequestServices; |
| | 0 | 70 | | var ctx = services.GetRequiredService<SykiDbContext>(); |
| | 0 | 71 | | var signInService = services.GetRequiredService<SignInService>(); |
| | 0 | 72 | | var frontendSettings = services.GetRequiredService<FrontendSettings>(); |
| | 0 | 73 | | var userManager = services.GetRequiredService<UserManager<SykiUser>>(); |
| | | 74 | | |
| | | 75 | | // 1. Extract email from OAuth claims |
| | 0 | 76 | | var email = context.Principal?.FindFirst(ClaimTypes.Email)?.Value ?? context.Principal?.FindFirst("email")?.Valu |
| | | 77 | | |
| | 0 | 78 | | if (email.IsEmpty()) |
| | | 79 | | { |
| | 0 | 80 | | context.Response.Redirect($"{frontendSettings.Url}?social_login_error={nameof(SocialLoginFailed)}"); |
| | 0 | 81 | | context.HandleResponse(); |
| | 0 | 82 | | return; |
| | | 83 | | } |
| | | 84 | | |
| | 0 | 85 | | email = email!.ToLowerInvariant(); |
| | | 86 | | |
| | | 87 | | // 2. Check email_verified claim (Google provides this) |
| | 0 | 88 | | var emailVerified = context.Principal?.FindFirst("email_verified")?.Value; |
| | 0 | 89 | | if (emailVerified != "true" && emailVerified != "True") |
| | | 90 | | { |
| | 0 | 91 | | context.Response.Redirect($"{frontendSettings.Url}?social_login_error={nameof(SocialLoginEmailNotVerified)}" |
| | 0 | 92 | | context.HandleResponse(); |
| | 0 | 93 | | return; |
| | | 94 | | } |
| | | 95 | | |
| | | 96 | | // 3. Check if email domain requires corporate SSO |
| | 0 | 97 | | if (await ctx.EmailRequiresSsoAsync(email)) |
| | | 98 | | { |
| | 0 | 99 | | context.Response.Redirect($"{frontendSettings.Url}?social_login_error={nameof(SocialLoginSsoRequired)}"); |
| | 0 | 100 | | context.HandleResponse(); |
| | 0 | 101 | | return; |
| | | 102 | | } |
| | | 103 | | |
| | | 104 | | // 4. Extract provider info |
| | 0 | 105 | | var provider = SocialLoginProvider.Google; |
| | 0 | 106 | | var providerKey = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? context.Principal?.FindFirst |
| | | 107 | | |
| | | 108 | | // 5. Look up existing social login link by (provider, providerKey) |
| | 0 | 109 | | var existingLink = await ctx.UserSocialLogins.FirstOrDefaultAsync(x => x.Provider == provider && x.ProviderKey = |
| | 0 | 110 | | if (existingLink != null) |
| | | 111 | | { |
| | | 112 | | // Already linked, generate JWT and login |
| | 0 | 113 | | await signInService.SignIn(existingLink.Email); |
| | | 114 | | |
| | 0 | 115 | | context.Response.Redirect(frontendSettings.BuildUrl("/home")); |
| | 0 | 116 | | context.HandleResponse(); |
| | 0 | 117 | | return; |
| | | 118 | | } |
| | | 119 | | |
| | | 120 | | // 6. Look up existing user by email (case-insensitive) |
| | 0 | 121 | | var existingUser = await ctx.Users.FirstOrDefaultAsync(u => u.Email == email); |
| | 0 | 122 | | if (existingUser != null) |
| | | 123 | | { |
| | | 124 | | // Link social account to existing user |
| | 0 | 125 | | existingUser.EmailConfirmed = true; |
| | 0 | 126 | | ctx.Add(new UserSocialLogin(existingUser.Id, provider, providerKey, email)); |
| | 0 | 127 | | await ctx.SaveChangesAsync(); |
| | | 128 | | |
| | 0 | 129 | | await signInService.SignIn(email); |
| | | 130 | | |
| | 0 | 131 | | context.Response.Redirect(frontendSettings.BuildUrl("/home")); |
| | 0 | 132 | | context.HandleResponse(); |
| | 0 | 133 | | return; |
| | | 134 | | } |
| | | 135 | | |
| | | 136 | | // 7. Auto-provision new user on public and web schemes |
| | 0 | 137 | | var name = ExtractName(context.Principal); |
| | 0 | 138 | | if (name.IsEmpty()) name = email; |
| | | 139 | | |
| | 0 | 140 | | var directorRole = await ctx.GetDirectorRole(); |
| | | 141 | | |
| | 0 | 142 | | var institution = Institution.NewForUserRegister(); |
| | 0 | 143 | | var user = new SykiUser(institution, name, email); |
| | 0 | 144 | | var userRole = new SykiUserRole(institution, user, directorRole.Id); |
| | 0 | 145 | | var socialLogin = new UserSocialLogin(user.Id, provider, providerKey, email) { User = user }; |
| | | 146 | | |
| | 0 | 147 | | ctx.AddRange(institution, userRole, socialLogin); |
| | 0 | 148 | | await userManager.CreateAsync(user, $"Syki@{Guid.NewGuid()}"); |
| | | 149 | | |
| | 0 | 150 | | await signInService.SignIn(email); |
| | | 151 | | |
| | 0 | 152 | | context.Response.Redirect(frontendSettings.BuildUrl("/home")); |
| | 0 | 153 | | context.HandleResponse(); |
| | 0 | 154 | | } |
| | | 155 | | |
| | | 156 | | private static string? ExtractName(ClaimsPrincipal? principal) |
| | | 157 | | { |
| | 0 | 158 | | if (principal == null) return "User"; |
| | | 159 | | |
| | 0 | 160 | | var givenName = principal.FindFirst(ClaimTypes.GivenName)?.Value; |
| | 0 | 161 | | var surname = principal.FindFirst(ClaimTypes.Surname)?.Value; |
| | | 162 | | |
| | 0 | 163 | | if (givenName.HasValue() && surname.HasValue()) return $"{givenName} {surname}"; |
| | | 164 | | |
| | 0 | 165 | | var name = principal.FindFirst(ClaimTypes.Name)?.Value ?? principal.FindFirst("name")?.Value; |
| | | 166 | | |
| | 0 | 167 | | return name; |
| | | 168 | | } |
| | | 169 | | } |