Feature/breadboard #4
@@ -0,0 +1,175 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Media.JoshHeaps.Net.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace Media.JoshHeaps.Net.Api;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("sso")]
|
||||||
|
public class SsoApi(DbExecutor db, IConfiguration config, ILogger<SsoApi> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("token")]
|
||||||
|
public async Task<IActionResult> Exchange([FromBody] SsoTokenRequest request)
|
||||||
|
{
|
||||||
|
if (request == null || string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.Code))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "client_id and code are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Request.Headers.TryGetValue("X-Client-Secret", out var providedSecret) || string.IsNullOrWhiteSpace(providedSecret))
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = "missing client credentials" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = SsoClientRegistry.Find(config, request.ClientId);
|
||||||
|
if (client == null || !BCrypt.Net.BCrypt.Verify(providedSecret!, client.ClientSecretHash))
|
||||||
|
{
|
||||||
|
logger.LogWarning("SSO token exchange failed: bad client credentials for {ClientId}", request.ClientId);
|
||||||
|
return Unauthorized(new { error = "invalid client credentials" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var codeHash = HashCode(request.Code);
|
||||||
|
var row = await ConsumeCodeAsync(codeHash);
|
||||||
|
if (row == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "invalid, expired, or already-used code" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(row.Value.ClientId, request.ClientId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "code was issued for a different client" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.AllowsRedirectUri(row.Value.RedirectUri))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "redirect_uri mismatch" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await LoadUserAsync(row.Value.UserId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "user no longer exists" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var jwt = IssueToken(user, request.ClientId);
|
||||||
|
return Ok(new SsoTokenResponse
|
||||||
|
{
|
||||||
|
AccessToken = jwt,
|
||||||
|
TokenType = "Bearer",
|
||||||
|
ExpiresIn = 300
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(long UserId, string ClientId, string RedirectUri)?> ConsumeCodeAsync(string codeHash)
|
||||||
|
{
|
||||||
|
var connectionString = config["connectionString"]!;
|
||||||
|
await using var conn = new NpgsqlConnection(connectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using var tx = await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
|
long userId;
|
||||||
|
string clientId;
|
||||||
|
string redirectUri;
|
||||||
|
|
||||||
|
await using (var select = new NpgsqlCommand(
|
||||||
|
@"SELECT user_id, client_id, redirect_uri
|
||||||
|
FROM app.sso_authorization_codes
|
||||||
|
WHERE code_hash = @h
|
||||||
|
AND consumed_at IS NULL
|
||||||
|
AND expires_at > NOW()
|
||||||
|
FOR UPDATE", conn, tx))
|
||||||
|
{
|
||||||
|
select.Parameters.AddWithValue("@h", codeHash);
|
||||||
|
await using var reader = await select.ExecuteReaderAsync();
|
||||||
|
if (!await reader.ReadAsync()) return null;
|
||||||
|
userId = reader.GetInt64(0);
|
||||||
|
clientId = reader.GetString(1);
|
||||||
|
redirectUri = reader.GetString(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var update = new NpgsqlCommand(
|
||||||
|
"UPDATE app.sso_authorization_codes SET consumed_at = NOW() WHERE code_hash = @h",
|
||||||
|
conn, tx))
|
||||||
|
{
|
||||||
|
update.Parameters.AddWithValue("@h", codeHash);
|
||||||
|
await update.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.CommitAsync();
|
||||||
|
return (userId, clientId, redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SsoUser?> LoadUserAsync(long userId)
|
||||||
|
{
|
||||||
|
return await db.ExecuteReaderAsync(
|
||||||
|
"SELECT id, email, username, email_verified FROM app.users WHERE id = @userId AND is_active = true",
|
||||||
|
reader => new SsoUser
|
||||||
|
{
|
||||||
|
Id = reader.GetInt64(0),
|
||||||
|
Email = reader.GetString(1),
|
||||||
|
Username = reader.GetString(2),
|
||||||
|
EmailVerified = reader.GetBoolean(3)
|
||||||
|
},
|
||||||
|
new { userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private string IssueToken(SsoUser user, string audience)
|
||||||
|
{
|
||||||
|
var jwtKey = config["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured");
|
||||||
|
var jwtIssuer = config["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured");
|
||||||
|
|
||||||
|
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
|
||||||
|
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
|
new Claim(ClaimTypes.Email, user.Email),
|
||||||
|
new Claim(ClaimTypes.Name, user.Username),
|
||||||
|
new Claim("EmailVerified", user.EmailVerified.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
|
||||||
|
};
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: jwtIssuer,
|
||||||
|
audience: audience,
|
||||||
|
claims: claims,
|
||||||
|
expires: DateTime.UtcNow.AddMinutes(5),
|
||||||
|
signingCredentials: credentials);
|
||||||
|
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashCode(string code)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
|
||||||
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SsoTokenRequest
|
||||||
|
{
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SsoTokenResponse
|
||||||
|
{
|
||||||
|
public string AccessToken { get; set; } = string.Empty;
|
||||||
|
public string TokenType { get; set; } = "Bearer";
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SsoUser
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public bool EmailVerified { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- SSO authorization codes for the OAuth2 authorization-code flow.
|
||||||
|
-- The raw code is never stored; we persist the SHA-256 hash only.
|
||||||
|
-- Codes are single-use and short-lived (see Sso:CodeLifetimeSeconds in config).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app.sso_authorization_codes (
|
||||||
|
code_hash TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sso_codes_expires ON app.sso_authorization_codes(expires_at);
|
||||||
@@ -43,6 +43,10 @@
|
|||||||
|
|
||||||
<form id="loginForm" method="post">
|
<form id="loginForm" method="post">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
|
@if (!string.IsNullOrEmpty(Model.ReturnUrl))
|
||||||
|
{
|
||||||
|
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" />
|
||||||
|
}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email" class="form-label">Email or Username</label>
|
<label for="email" class="form-label">Email or Username</label>
|
||||||
<input type="text" class="form-control" id="email" name="email"
|
<input type="text" class="form-control" id="email" name="email"
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ public class LoginModel(AuthService authService) : PageModel
|
|||||||
[BindProperty]
|
[BindProperty]
|
||||||
public bool RememberMe { get; set; }
|
public bool RememberMe { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
public string? SuccessMessage { get; set; }
|
public string? SuccessMessage { get; set; }
|
||||||
public string? WarningMessage { get; set; }
|
public string? WarningMessage { get; set; }
|
||||||
@@ -26,7 +29,7 @@ public class LoginModel(AuthService authService) : PageModel
|
|||||||
var userId = HttpContext.Session.GetString("UserId");
|
var userId = HttpContext.Session.GetString("UserId");
|
||||||
if (!string.IsNullOrEmpty(userId))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
Response.Redirect("/Landing");
|
Response.Redirect(SafeReturnUrl() ?? "/Landing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +93,9 @@ public class LoginModel(AuthService authService) : PageModel
|
|||||||
Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions);
|
Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Redirect("/Landing");
|
return Redirect(SafeReturnUrl() ?? "/Landing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? SafeReturnUrl() =>
|
||||||
|
!string.IsNullOrWhiteSpace(ReturnUrl) && Url.IsLocalUrl(ReturnUrl) ? ReturnUrl : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@page "/sso/authorize"
|
||||||
|
@model Media.JoshHeaps.Net.Pages.Sso.AuthorizeModel
|
||||||
|
@{
|
||||||
|
Layout = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Media.JoshHeaps.Net.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Media.JoshHeaps.Net.Pages.Sso;
|
||||||
|
|
||||||
|
public class AuthorizeModel(DbExecutor db, IConfiguration config, ILogger<AuthorizeModel> logger) : AuthenticatedPageModel
|
||||||
|
{
|
||||||
|
public async Task<IActionResult> OnGetAsync(
|
||||||
|
[FromQuery(Name = "client_id")] string? clientId,
|
||||||
|
[FromQuery(Name = "redirect_uri")] string? redirectUri,
|
||||||
|
[FromQuery] string? state)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(redirectUri) || string.IsNullOrWhiteSpace(state))
|
||||||
|
{
|
||||||
|
return BadRequest("client_id, redirect_uri, and state are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = SsoClientRegistry.Find(config, clientId);
|
||||||
|
if (client == null) return BadRequest("unknown client_id");
|
||||||
|
if (!client.AllowsRedirectUri(redirectUri)) return BadRequest("redirect_uri is not registered for this client");
|
||||||
|
|
||||||
|
if (!IsAuthenticated())
|
||||||
|
{
|
||||||
|
var original = $"/sso/authorize?client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(redirectUri)}&state={Uri.EscapeDataString(state)}";
|
||||||
|
return Redirect($"/Login?ReturnUrl={Uri.EscapeDataString(original)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadUserSession();
|
||||||
|
|
||||||
|
var code = GenerateCode();
|
||||||
|
var codeHash = HashCode(code);
|
||||||
|
var lifetime = int.TryParse(config["Sso:CodeLifetimeSeconds"], out var s) ? s : 60;
|
||||||
|
var expiresAt = DateTimeOffset.UtcNow.AddSeconds(lifetime);
|
||||||
|
|
||||||
|
await db.ExecuteNonQueryAsync(
|
||||||
|
@"INSERT INTO app.sso_authorization_codes (code_hash, client_id, user_id, redirect_uri, expires_at)
|
||||||
|
VALUES (@codeHash, @clientId, @userId, @redirectUri, @expiresAt)",
|
||||||
|
new { codeHash, clientId, userId = UserId, redirectUri, expiresAt });
|
||||||
|
|
||||||
|
logger.LogInformation("SSO code issued for user {UserId} to client {ClientId}", UserId, clientId);
|
||||||
|
|
||||||
|
var separator = redirectUri.Contains('?') ? '&' : '?';
|
||||||
|
return Redirect($"{redirectUri}{separator}code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateCode()
|
||||||
|
{
|
||||||
|
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||||
|
return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashCode(string code)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
|
||||||
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Media.JoshHeaps.Net.Services;
|
||||||
|
|
||||||
|
public sealed class SsoClientConfig
|
||||||
|
{
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string ClientSecretHash { get; set; } = string.Empty;
|
||||||
|
public List<string> RedirectUris { get; set; } = [];
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool AllowsRedirectUri(string uri) =>
|
||||||
|
RedirectUris.Any(r => string.Equals(r, uri, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SsoClientRegistry
|
||||||
|
{
|
||||||
|
public static SsoClientConfig? Find(IConfiguration config, string clientId)
|
||||||
|
{
|
||||||
|
var clients = config.GetSection("Sso:Clients").Get<List<SsoClientConfig>>() ?? [];
|
||||||
|
return clients.FirstOrDefault(c => string.Equals(c.ClientId, clientId, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,17 @@
|
|||||||
"InvalidateUrl": "https://joshheaps.net/api/blog/invalidate",
|
"InvalidateUrl": "https://joshheaps.net/api/blog/invalidate",
|
||||||
"InvalidateKey": "CHANGE_ME"
|
"InvalidateKey": "CHANGE_ME"
|
||||||
},
|
},
|
||||||
|
"Sso": {
|
||||||
|
"CodeLifetimeSeconds": 60,
|
||||||
|
"Clients": [
|
||||||
|
{
|
||||||
|
"ClientId": "ai",
|
||||||
|
"ClientSecretHash": "SET_VIA_USER_SECRETS",
|
||||||
|
"RedirectUris": [ "https://ai.joshheaps.net/auth/callback" ],
|
||||||
|
"Name": "AI JoshHeaps"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"FileUpload": {
|
"FileUpload": {
|
||||||
"MaxFileSizeMB": 10,
|
"MaxFileSizeMB": 10,
|
||||||
"AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
"AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
||||||
|
|||||||
Reference in New Issue
Block a user