-
Notifications
You must be signed in to change notification settings - Fork 21
LTI: поддержка протокола LTI 1.3 для интеграции с внешними инструментами. #655
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
e67db88
e96c761
fac5635
e0ce973
6d1c5c3
6dda568
aed3452
a74f3f0
47455bc
f02fd06
a21edf9
1a92b69
a83f718
548e1e3
22b513d
8db5f7d
5233e52
55c2f76
7ec4f6a
16a57bc
4075fde
d13ff7b
03ee496
7d2c0fb
cb113d1
cb091b0
efe2b8f
b9efaf0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -362,3 +362,4 @@ StyleCop.Cache | |
| swagger-codegen | ||
| hwproj.front/static_dist/ | ||
| hwproj.front/dist/ | ||
| .DS_Store | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,19 @@ | ||||||
| namespace HwProj.APIGateway.API.Lti.Configuration; | ||||||
|
|
||||||
| public class LtiPlatformConfig | ||||||
| { | ||||||
| public string Issuer { get; set; } | ||||||
| public string OidcAuthorizationEndpoint { get; set; } | ||||||
| public string DeepLinkReturnUrl { get; set; } | ||||||
| public string ResourceLinkReturnUrl { get; set; } | ||||||
| public string AssignmentsGradesEndpoint { get; set; } | ||||||
| public string AccessTokenUrl { get; set; } | ||||||
| public string JwksEndpoint { get; set; } | ||||||
| public LtiSigningKeyConfig SigningKey { get; set; } | ||||||
| } | ||||||
|
|
||||||
| public class LtiSigningKeyConfig | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. см. ответ сверху |
||||||
| { | ||||||
| public string KeyId { get; set; } | ||||||
| public string PrivateKeyPem { get; set; } | ||||||
| } | ||||||
|
KirillBorisovich marked this conversation as resolved.
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,13 @@ | ||||||
| namespace HwProj.APIGateway.API.Lti.Configuration | ||||||
| { | ||||||
| public class LtiToolConfig | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. см. ответ сверху |
||||||
| { | ||||||
| public string Name { get; set; } | ||||||
| public string Issuer { get; set; } | ||||||
| public string ClientId { get; set; } | ||||||
| public string JwksEndpoint { get; set; } | ||||||
| public string InitiateLoginUri { get; set; } | ||||||
| public string LaunchUrl { get; set; } | ||||||
| public string DeepLink { get; set; } | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| using System.Security.Cryptography; | ||
| using HwProj.APIGateway.API.Lti.Configuration; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.Extensions.Options; | ||
| using Microsoft.IdentityModel.Tokens; | ||
|
|
||
| namespace HwProj.APIGateway.API.Lti.Controllers; | ||
|
|
||
| [Route("api/lti")] | ||
| [ApiController] | ||
| public class JwksController(IOptions<LtiPlatformConfig> options) : ControllerBase | ||
| { | ||
| private readonly LtiPlatformConfig _config = options.Value; | ||
|
|
||
| [HttpGet("jwks")] | ||
| [AllowAnonymous] | ||
| public IActionResult GetJwks() | ||
| { | ||
| var keyConfig = _config.SigningKey; | ||
|
|
||
| if (string.IsNullOrEmpty(keyConfig?.PrivateKeyPem)) | ||
| { | ||
| return StatusCode(500, "Signing key is not configured."); | ||
| } | ||
|
|
||
| using var rsa = RSA.Create(); | ||
| try | ||
| { | ||
| rsa.ImportFromPem(keyConfig.PrivateKeyPem); | ||
| } | ||
| catch (CryptographicException) | ||
| { | ||
| return StatusCode(500, "Invalid Private Key format in configuration."); | ||
| } | ||
|
|
||
| var publicParams = rsa.ExportParameters(false); | ||
|
|
||
| var jwks = new | ||
| { | ||
| keys = new[] | ||
| { | ||
| new | ||
| { | ||
| kty = "RSA", | ||
| e = Base64UrlEncoder.Encode(publicParams.Exponent), | ||
| n = Base64UrlEncoder.Encode(publicParams.Modulus), | ||
| kid = keyConfig.KeyId, | ||
| alg = "RS256", | ||
| use = "sig" | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| return Ok(jwks); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| using System; | ||
| using System.IdentityModel.Tokens.Jwt; | ||
| using System.Threading.Tasks; | ||
| using HwProj.APIGateway.API.Lti.Configuration; | ||
| using HwProj.APIGateway.API.Lti.Services; | ||
| using HwProj.APIGateway.API.LTI.Services; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Http; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.Extensions.Options; | ||
| using Microsoft.IdentityModel.Tokens; | ||
|
|
||
| namespace HwProj.APIGateway.API.Lti.Controllers; | ||
|
|
||
| [Route("api/lti")] | ||
| [ApiController] | ||
| public class LtiAccessTokenController( | ||
| IOptions<LtiPlatformConfig> options, | ||
| ILtiToolService toolService, | ||
| ILtiKeyService ltiKeyService, | ||
| ILtiTokenService tokenService | ||
| ) : ControllerBase | ||
| { | ||
| [HttpPost("token")] | ||
| [AllowAnonymous] | ||
| public async Task<IActionResult> GetTokenAsync([FromForm] IFormCollection form) | ||
| { | ||
| if (!form.TryGetValue("grant_type", out var grantType) || grantType != "client_credentials") | ||
| { | ||
| return BadRequest(new { error = "unsupported_grant_type", error_description = "Only 'client_credentials' is supported." }); | ||
| } | ||
|
|
||
| if (!form.TryGetValue("client_assertion_type", out var assertionType) || | ||
| assertionType != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") | ||
| { | ||
| return BadRequest(new { error = "invalid_request", error_description = "Invalid client_assertion_type." }); | ||
| } | ||
|
|
||
| if (!form.TryGetValue("client_assertion", out var clientAssertion)) | ||
| { | ||
| return BadRequest(new { error = "invalid_request", error_description = "Missing client_assertion." }); | ||
| } | ||
|
|
||
| var handler = new JwtSecurityTokenHandler(); | ||
| if (!handler.CanReadToken(clientAssertion)) | ||
| { | ||
| return BadRequest(new { error = "invalid_client", error_description = "Invalid JWT structure." }); | ||
| } | ||
|
|
||
| var unverifiedToken = handler.ReadJwtToken(clientAssertion); | ||
|
|
||
| var clientId = unverifiedToken.Subject; | ||
|
|
||
| var tool = toolService.GetByClientId(clientId); | ||
| if (tool == null) | ||
| { | ||
| return Unauthorized(new { error = "invalid_client", error_description = $"Unknown clientId: {clientId}" }); | ||
| } | ||
|
|
||
| var signingKeys = await ltiKeyService.GetKeysAsync(tool.JwksEndpoint); | ||
|
|
||
| try | ||
| { | ||
| var tokenEndpointUrl = options.Value.AccessTokenUrl; | ||
|
|
||
| handler.ValidateToken(clientAssertion, new TokenValidationParameters | ||
| { | ||
| ValidateIssuer = true, | ||
| ValidIssuer = unverifiedToken.Issuer, | ||
|
|
||
| ValidateAudience = true, | ||
| ValidAudience = tokenEndpointUrl, | ||
|
|
||
| ValidateLifetime = true, | ||
| ClockSkew = TimeSpan.FromMinutes(5), | ||
|
|
||
| ValidateIssuerSigningKey = true, | ||
| IssuerSigningKeys = signingKeys | ||
| }, out _); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| return Unauthorized(new { error = "invalid_client", error_description = $"Token validation failed: {ex.Message}" }); | ||
| } | ||
|
|
||
| const string scope = "https://purl.imsglobal.org/spec/lti-ags/scope/score"; | ||
|
|
||
| var accessToken = tokenService.GenerateAccessTokenForLti(tool.ClientId, scope); | ||
|
|
||
| return Ok(new | ||
| { | ||
| access_token = accessToken, | ||
| token_type = "Bearer", | ||
| expires_in = 3600, | ||
| scope | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Security.Claims; | ||
| using System.Threading.Tasks; | ||
| using HwProj.APIGateway.API.Lti.Services; | ||
| using HwProj.CoursesService.Client; | ||
| using HwProj.Models.SolutionsService; | ||
| using HwProj.SolutionsService.Client; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using LtiAdvantage.AssignmentGradeServices; | ||
|
|
||
| namespace HwProj.APIGateway.API.Lti.Controllers; | ||
|
|
||
| [Route("api/lti")] | ||
| [ApiController] | ||
| [Authorize(AuthenticationSchemes = "LtiScheme")] | ||
| public class LtiAssignmentsGradesControllers( | ||
| ICoursesServiceClient coursesServiceClient, | ||
| ISolutionsServiceClient solutionsClient, | ||
| ILtiToolService toolService) | ||
| : ControllerBase | ||
| { | ||
| [HttpPost("lineItem/{taskId}/scores")] | ||
| [Consumes("application/json", "application/vnd.ims.lis.v1.score+json")] | ||
| public async Task<IActionResult> UpdateTaskScore(long taskId, [FromBody] Score score) | ||
| { | ||
| var scopeClaim = User.FindFirst("scope")?.Value; | ||
| if (string.IsNullOrEmpty(scopeClaim) || !scopeClaim.Contains("https://purl.imsglobal.org/spec/lti-ags/scope/score")) | ||
| { | ||
| return Forbid(); | ||
| } | ||
|
|
||
| var toolClientId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value | ||
| ?? User.FindFirst("sub")?.Value; | ||
|
|
||
| if (string.IsNullOrEmpty(toolClientId)) | ||
| { | ||
| return Unauthorized("Unknown tool client id."); | ||
| } | ||
|
|
||
| var tool = toolService.GetByClientId(toolClientId); | ||
| if (tool == null) | ||
| { | ||
| return BadRequest("Tool not found."); | ||
| } | ||
|
|
||
| var course = await coursesServiceClient.GetCourseByTaskForLti(taskId, score.UserId); | ||
| if (course == null) | ||
| { | ||
| return BadRequest("The task does not belong to any course."); | ||
| } | ||
|
|
||
| if (course.LtiToolName != tool.Name) | ||
| { | ||
| return BadRequest("This tool does not apply to this course."); | ||
| } | ||
|
|
||
| if (score.ScoreGiven < 0 || score.ScoreGiven > score.ScoreMaximum) | ||
| { | ||
| return BadRequest("ScoreGiven must be between 0 and ScoreMaximum."); | ||
| } | ||
|
|
||
| try | ||
| { | ||
| await this.SetTaskGrade(taskId, score); | ||
| return Ok(new { message = "Score updated successfully" }); | ||
| } | ||
| catch (KeyNotFoundException ex) | ||
| { | ||
| return NotFound(ex.Message); | ||
| } | ||
| catch (Exception) | ||
| { | ||
| return StatusCode(500, "Internal Server Error"); | ||
| } | ||
| } | ||
|
|
||
| private async Task SetTaskGrade(long taskId, Score score) | ||
| { | ||
| var postSolutionModel = new PostSolutionModel | ||
| { | ||
| StudentId = score.UserId, | ||
| LecturerComment = score.Comment, | ||
| Rating = (int)Math.Round(score.ScoreGiven) | ||
|
Comment on lines
+84
to
+85
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Давай добавим оценку в комментарий |
||
| }; | ||
|
|
||
|
|
||
| var solutionId = await solutionsClient.PostSolutionForLti(taskId, postSolutionModel); | ||
|
|
||
| var rate = new RateSolutionModel | ||
| { | ||
| Rating = (int)Math.Round(score.ScoreGiven), | ||
| LecturerComment = score.Comment | ||
| }; | ||
|
|
||
| await solutionsClient.RateSolutionForLti(solutionId, rate); | ||
|
Comment on lines
+89
to
+97
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Можно объединить в один запрос/метод |
||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
А нужно ли? Я в конструкторах использую часто где IOptions options. Чтобы сделать внутренним, мне нужно пренести всю логику в сервисы и сделать их (сервисы) все внутренними, нужно ли так делать? Просто больше в проекте нигде так не делается