Initial commit: standalone quiz-testing system

Full-stack F# (Domain/Server/Client via Fable+Elmish+Feliz), PostgreSQL
persistence via Dapper, Docker Compose deployment. Student quiz-taking flow
with time-limit enforcement and focus-loss tracking, Teacher question bank
and quiz builder with results analytics, Admin user management.
This commit is contained in:
danamir
2026-08-06 12:36:16 +03:00
commit 942dfc9c1a
134 changed files with 10712 additions and 0 deletions

79
src/Server/Auth.fs Normal file
View File

@@ -0,0 +1,79 @@
module Server.Auth
open System
open System.Security.Claims
open System.Text
open System.IdentityModel.Tokens.Jwt
open Microsoft.IdentityModel.Tokens
open Domain
let private issuer = "quizsystem"
let private audience = "quizsystem-client"
let private signingKey (secret: string) = SymmetricSecurityKey(Encoding.UTF8.GetBytes secret)
let issueToken (secret: string) (user: User) : string =
let creds = SigningCredentials(signingKey secret, SecurityAlgorithms.HmacSha256)
let (UserId rawId) = user.Id
let claims =
[| Claim(JwtRegisteredClaimNames.Sub, string rawId)
Claim(ClaimTypes.Name, user.Name)
Claim(ClaimTypes.Email, user.Email)
Claim(ClaimTypes.Role, string user.Role) |]
let token =
JwtSecurityToken(
issuer = issuer,
audience = audience,
claims = claims,
expires = DateTime.UtcNow.AddHours 8.0,
signingCredentials = creds
)
JwtSecurityTokenHandler().WriteToken token
let tokenValidationParameters (secret: string) =
TokenValidationParameters(
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey secret,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes 1.0
)
let tryGetUserId (principal: ClaimsPrincipal) : UserId option =
match principal.FindFirst(JwtRegisteredClaimNames.Sub) with
| null -> None
| claim ->
match Guid.TryParse claim.Value with
| true, guid -> Some(UserId guid)
| false, _ -> None
/// Shared by every handler that requires a signed-in user, so each feature
/// doesn't repeat its own "Требуется авторизация" error text.
let requireUserId (principal: ClaimsPrincipal) : Result<UserId, string> =
match tryGetUserId principal with
| Some uid -> Ok uid
| None -> Error "Требуется авторизация"
let private tryGetRole (principal: ClaimsPrincipal) : Role option =
match principal.FindFirst(ClaimTypes.Role) with
| null -> None
| claim ->
match claim.Value with
| "Admin" -> Some Admin
| "Teacher" -> Some Teacher
| "Student" -> Some Student
| _ -> None
/// Like `requireUserId`, but also checks the JWT's role claim is one of
/// `allowed` used by Teacher/Admin-only handlers.
let requireRole (allowed: Role list) (principal: ClaimsPrincipal) : Result<UserId, string> =
match tryGetUserId principal, tryGetRole principal with
| Some uid, Some role when List.contains role allowed -> Ok uid
| Some _, Some _ -> Error "Доступ запрещён"
| _ -> Error "Требуется авторизация"