Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e67db88
fix: made it possible to run on macOS
KirillBorisovich Nov 30, 2025
e96c761
feat: made a controller for OIDC
KirillBorisovich Nov 30, 2025
fac5635
feat: added a repository for storing the connection between HomeworkT…
KirillBorisovich Dec 4, 2025
e0ce973
feat: добавил возможность выбора LtiTool при создании курса
KirillBorisovich Dec 5, 2025
6d1c5c3
Merge branch 'InteIIigeNET:master' into master
KirillBorisovich Feb 24, 2026
6dda568
feat: почти доделал deeplinking
KirillBorisovich Jan 3, 2026
aed3452
feat: made front-end support for deeplinking
KirillBorisovich Jan 4, 2026
a74f3f0
feat: migrated the database
KirillBorisovich Jan 4, 2026
47455bc
feat: debugged the deeplinking implementation and added it to the cor…
KirillBorisovich Jan 6, 2026
f02fd06
fix: fixed a bug with the presence of ltiLaunchUrl in the server resp…
KirillBorisovich Jan 6, 2026
a21edf9
refactor: deleted unnecessary folders
KirillBorisovich Jan 6, 2026
1a92b69
feat: did deeplinking
KirillBorisovich Jan 6, 2026
a83f718
feat: made it possible to take into account the maximum score when im…
KirillBorisovich Jan 7, 2026
548e1e3
feat: support test launches through LTI. Final grading is not included.
KirillBorisovich Feb 18, 2026
22b513d
feat: added functionality for LTI tools to report scores
KirillBorisovich Feb 23, 2026
8db5f7d
refactor: deleted MockToolController
KirillBorisovich Feb 23, 2026
5233e52
fix: enabled notifications
KirillBorisovich Feb 23, 2026
55c2f76
fix and refactor: fixed a bug in the signature verification in LtiDee…
KirillBorisovich Feb 23, 2026
7ec4f6a
feat: added a pop-up window to warn you before you start testing
KirillBorisovich Feb 24, 2026
16a57bc
fix: added a parameter required for the protocol
KirillBorisovich Mar 1, 2026
4075fde
refactor: added appsettings.json
KirillBorisovich Mar 1, 2026
d13ff7b
Merge branch master
KirillBorisovich Mar 2, 2026
03ee496
refactor: updated appsettings.json and deleted MockToolController
KirillBorisovich Mar 2, 2026
7d2c0fb
feat: added LtiMockTool
KirillBorisovich Mar 20, 2026
cb113d1
refactor: made almost all the necessary edits
KirillBorisovich Mar 21, 2026
cb091b0
refactor: implement ForeignKey relationship for HomeworkTaskLtiLaunch…
KirillBorisovich Mar 22, 2026
efe2b8f
refactor: update appsettings.json
KirillBorisovich Mar 22, 2026
b9efaf0
refactor: returned the appsettings.json
KirillBorisovich Mar 22, 2026
b6a268b
refactor: improved the structure in LtiKeyServices and HomeworkContro…
KirillBorisovich Apr 25, 2026
525c1e6
refactor: changed the EventBus credits, combined the sending and eval…
KirillBorisovich Apr 25, 2026
b59a18a
fix: Improved the LTI tool response parser and mitigated XSS vulnerab…
KirillBorisovich Apr 26, 2026
740c562
refactor: moved the logic of filling tasks with LTI information to Ta…
KirillBorisovich Apr 28, 2026
2f3c1d6
fix: fixed an inaccuracy in the protocol implementation
KirillBorisovich Jun 8, 2026
08e3707
feat: added lti bot initialization
KirillBorisovich Aug 2, 2026
edd1301
Revert "feat: added lti bot initialization"
KirillBorisovich Aug 15, 2026
d2fb370
feat: implement obtaining or creating an LTI bot by ClientID
KirillBorisovich Aug 15, 2026
4053dc5
feat: added automatic creation of an LTI bot and its appointment as a…
KirillBorisovich Aug 15, 2026
8054954
refactor: duplication of LTI logic has been removed and grading on be…
KirillBorisovich Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -3,6 +3,7 @@
using System.Net;
using System.Threading.Tasks;
using AutoMapper;
using HwProj.APIGateway.API.Lti.Services;
using HwProj.APIGateway.API.Models;
using HwProj.AuthService.Client;
using HwProj.CoursesService.Client;
Expand All @@ -22,16 +23,19 @@ namespace HwProj.APIGateway.API.Controllers;
public class CoursesController : AggregationController
{
private readonly ICoursesServiceClient _coursesClient;
private readonly ILtiToolService _ltiToolService;
private readonly IMapper _mapper;
private readonly IStudentsInformationProvider _studentsInfo;

public CoursesController(
ICoursesServiceClient coursesClient,
ILtiToolService ltiToolService,
IAuthServiceClient authServiceClient,
IMapper mapper,
IStudentsInformationProvider studentsInfo) : base(authServiceClient)
{
_coursesClient = coursesClient;
_ltiToolService = ltiToolService;
_mapper = mapper;
_studentsInfo = studentsInfo;
}
Expand Down Expand Up @@ -103,6 +107,40 @@ public async Task<IActionResult> GetProgramNames()
[ProducesResponseType(typeof(long), (int)HttpStatusCode.OK)]
public async Task<IActionResult> CreateCourse(CreateCourseViewModel model)
{
AccountDataDto? ltiBot = null;

if (!string.IsNullOrWhiteSpace(model.LtiToolName))
{
var ltiTool = _ltiToolService.GetByName(model.LtiToolName);
if (ltiTool == null)
{
return BadRequest($"LTI-инструмент '{model.LtiToolName}' не найден");
}

var botResult = await AuthServiceClient.GetOrCreateLtiBot(ltiTool.ClientId);
if (!botResult.Succeeded)
{
return StatusCode(
(int)HttpStatusCode.ServiceUnavailable,
botResult.Errors);
}

ltiBot = botResult.Value;
if (ltiBot == null)
{
return StatusCode(
(int)HttpStatusCode.InternalServerError,
"LTI-бот был создан, но его данные не найдены");
}

if (ltiBot.Role != Roles.ExpertRole)
{
return StatusCode(
(int)HttpStatusCode.InternalServerError,
"LTI-бот не имеет роли Expert");
}
}

if (model.GroupNames.Any() && model.FetchStudents)
{
var studentCandidates = new List<StudentModel>();
Expand Down Expand Up @@ -138,9 +176,33 @@ public async Task<IActionResult> CreateCourse(CreateCourseViewModel model)
}

var result = await _coursesClient.CreateCourse(model);
return result.Succeeded
? Ok(result.Value)
: BadRequest(result.Errors);
if (!result.Succeeded)
{
return BadRequest(result.Errors);
}

if (ltiBot != null)
{
var addExpertResult = await _coursesClient.AcceptLecturer(
result.Value,
ltiBot.Email,
ltiBot.UserId,
sendNotification: false);

if (!addExpertResult.Succeeded)
{
return StatusCode(
(int)HttpStatusCode.InternalServerError,
new
{
CourseId = result.Value,
Errors = addExpertResult.Errors,
Message = "Курс создан, но LTI-бот не был добавлен как эксперт"
});
}
}

return Ok(result.Value);
}

[HttpPost("update/{courseId}")]
Expand Down Expand Up @@ -309,7 +371,8 @@ private async Task<CourseViewModel> ToCourseViewModel(CourseDTO course)
NewStudents = newStudents.ToArray(),
Homeworks = course.Homeworks,
IsCompleted = course.IsCompleted,
IsOpen = course.IsOpen
IsOpen = course.IsOpen,
LtiToolName = course.LtiToolName,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ public async Task<IActionResult> GetStudentSolution(long taskId, string studentI
return Ok(new UserTaskSolutionsPageData
{
CourseId = course.Id,
LtiToolName = course.LtiToolName,
CourseMates = accounts,
TaskSolutions = taskSolutions
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="LtiAdvantage" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.20" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="AutoMapper" Version="15.0.1" />
Expand Down
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
{
public string KeyId { get; set; }
public string PrivateKeyPem { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace HwProj.APIGateway.API.Lti.Configuration
{
public class LtiToolConfig
{
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,113 @@
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 HwProj.AuthService.Client;
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,
IAuthServiceClient authServiceClient
) : 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.Issuer;

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 = tool.ClientId,

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 botResult = await authServiceClient.GetOrCreateLtiBot(tool.ClientId);
if (!botResult.Succeeded || botResult.Value == null)
{
return StatusCode(StatusCodes.Status500InternalServerError, new
{
error = "server_error",
error_description = "Failed to resolve the LTI bot account."
});
}

var accessToken = tokenService.GenerateAccessTokenForLti(
tool.ClientId,
botResult.Value.UserId,
scope);

return Ok(new
{
access_token = accessToken,
token_type = "Bearer",
expires_in = 3600,
scope
});
}
}
Loading