seb-win-refactoring/SafeExamBrowser.Server/ServerProxy.cs

544 lines
14 KiB
C#

/*
* Copyright (c) 2020 ETH Zürich, Educational Development and Technology (LET)
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using System.Timers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Server.Contracts;
using SafeExamBrowser.Server.Contracts.Data;
using SafeExamBrowser.Server.Data;
using SafeExamBrowser.Settings.Logging;
using SafeExamBrowser.Settings.Server;
namespace SafeExamBrowser.Server
{
public class ServerProxy : ILogObserver, IServerProxy
{
private ApiVersion1 api;
private AppConfig appConfig;
private string connectionToken;
private string examId;
private HttpClient httpClient;
private ILogger logger;
private ConcurrentQueue<ILogContent> logContent;
private string oauth2Token;
private int pingNumber;
private ServerSettings settings;
private Timer timer;
public ServerProxy(AppConfig appConfig, ILogger logger)
{
this.api = new ApiVersion1();
this.appConfig = appConfig;
this.httpClient = new HttpClient();
this.logger = logger;
this.logContent = new ConcurrentQueue<ILogContent>();
this.timer = new Timer();
}
public ServerResponse Connect()
{
var success = TryExecute(HttpMethod.Get, settings.ApiUrl, out var response);
var message = ToString(response);
if (success && TryParseApi(response.Content))
{
logger.Info("Successfully loaded server API.");
var secret = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{settings.ClientName}:{settings.ClientSecret}"));
var authorization = ("Authorization", $"Basic {secret}");
var content = "grant_type=client_credentials&scope=read write";
var contentType = "application/x-www-form-urlencoded";
success = TryExecute(HttpMethod.Post, api.AccessTokenEndpoint, out response, content, contentType, authorization);
message = ToString(response);
if (success && TryParseOauth2Token(response.Content))
{
logger.Info("Successfully retrieved OAuth2 token.");
}
else
{
logger.Error("Failed to retrieve OAuth2 token!");
}
}
else
{
logger.Error("Failed to load server API!");
}
return new ServerResponse(success, message);
}
public ServerResponse Disconnect()
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var contentType = "application/x-www-form-urlencoded";
var token = ("SEBConnectionToken", connectionToken);
var success = TryExecute(HttpMethod.Delete, api.HandshakeEndpoint, out var response, default(string), contentType, authorization, token);
var message = ToString(response);
if (success)
{
logger.Info("Successfully terminated connection.");
}
else
{
logger.Error("Failed to terminate connection!");
}
return new ServerResponse(success, message);
}
public ServerResponse<IEnumerable<Exam>> GetAvailableExams()
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var content = $"institutionId={settings.Institution}";
var contentType = "application/x-www-form-urlencoded";
var exams = default(IList<Exam>);
var success = TryExecute(HttpMethod.Post, api.HandshakeEndpoint, out var response, content, contentType, authorization);
var message = ToString(response);
if (success)
{
var hasExams = TryParseExams(response.Content, out exams);
var hasToken = TryParseConnectionToken(response);
success = hasExams && hasToken;
if (success)
{
logger.Info("Successfully retrieved connection token and available exams.");
}
else if (!hasExams)
{
logger.Error("Failed to retrieve available exams!");
}
else if (!hasToken)
{
logger.Error("Failed to retrieve connection token!");
}
}
else
{
logger.Error("Failed to load connection token and available exams!");
}
return new ServerResponse<IEnumerable<Exam>>(success, exams, message);
}
public ServerResponse<Uri> GetConfigurationFor(Exam exam)
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var token = ("SEBConnectionToken", connectionToken);
var uri = default(Uri);
var success = TryExecute(HttpMethod.Get, $"{api.ConfigurationEndpoint}?examId={exam.Id}", out var response, default(string), default(string), authorization, token);
var message = ToString(response);
if (success)
{
logger.Info("Successfully retrieved exam configuration.");
success = TrySaveFile(response.Content, out uri);
if (success)
{
logger.Info($"Successfully saved exam configuration as '{uri}'.");
}
else
{
logger.Error("Failed to save exam configuration!");
}
}
else
{
logger.Error("Failed to retrieve exam configuration!");
}
return new ServerResponse<Uri>(success, uri, message);
}
public ConnectionInfo GetConnectionInfo()
{
return new ConnectionInfo
{
Api = JsonConvert.SerializeObject(api),
ConnectionToken = connectionToken,
Oauth2Token = oauth2Token
};
}
public void Initialize(ServerSettings settings)
{
this.settings = settings;
httpClient.BaseAddress = new Uri(settings.ServerUrl);
if (settings.RequestTimeout > 0)
{
httpClient.Timeout = TimeSpan.FromMilliseconds(settings.RequestTimeout);
}
}
public void Initialize(string api, string connectionToken, string examId, string oauth2Token, ServerSettings settings)
{
this.api = JsonConvert.DeserializeObject<ApiVersion1>(api);
this.connectionToken = connectionToken;
this.examId = examId;
this.oauth2Token = oauth2Token;
Initialize(settings);
}
public void Notify(ILogContent content)
{
logContent.Enqueue(content);
}
public ServerResponse SendSessionIdentifier(string identifier)
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var content = $"examId={examId}&seb_user_session_id={identifier}";
var contentType = "application/x-www-form-urlencoded";
var token = ("SEBConnectionToken", connectionToken);
var success = TryExecute(HttpMethod.Put, api.HandshakeEndpoint, out var response, content, contentType, authorization, token);
var message = ToString(response);
if (success)
{
logger.Info("Successfully sent session identifier.");
}
else
{
logger.Error("Failed to send session identifier!");
}
return new ServerResponse(success, message);
}
public void StartConnectivity()
{
foreach (var item in logger.GetLog())
{
logContent.Enqueue(item);
}
logger.Subscribe(this);
timer.AutoReset = false;
timer.Elapsed += Timer_Elapsed;
timer.Interval = 1000;
timer.Start();
}
public void StopConnectivity()
{
logger.Unsubscribe(this);
timer.Stop();
timer.Elapsed -= Timer_Elapsed;
}
private void Timer_Elapsed(object sender, ElapsedEventArgs args)
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var token = ("SEBConnectionToken", connectionToken);
try
{
var content = $"timestamp={DateTime.Now.Ticks}&ping-number={++pingNumber}";
var contentType = "application/x-www-form-urlencoded";
var success = TryExecute(HttpMethod.Post, api.PingEndpoint, out var response, content, contentType, authorization, token);
if (success)
{
// TODO: Fire event if instruction is sent via response!
}
else
{
logger.Error($"Failed to send ping: {ToString(response)}");
}
}
catch (Exception e)
{
logger.Error("Failed to send ping!", e);
}
try
{
if (logContent.TryDequeue(out var c) && c is ILogMessage message)
{
var json = new JObject
{
["type"] = ToLogType(message.Severity),
["timestamp"] = message.DateTime.Ticks,
["text"] = message.Message
};
var content = json.ToString();
var contentType = "application/json;charset=UTF-8";
// TODO: Why can't we send multiple log messages in one request?
var success = TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, content, contentType, authorization, token);
}
}
catch (Exception e)
{
logger.Error("Failed to send log!", e);
}
timer.Start();
}
private bool TryParseApi(HttpContent content)
{
var success = false;
try
{
var json = JsonConvert.DeserializeObject(Extract(content)) as JObject;
var apis = json["api-versions"];
foreach (var api in apis.AsJEnumerable())
{
if (api["name"].Value<string>().Equals("v1"))
{
foreach (var endpoint in api["endpoints"].AsJEnumerable())
{
var name = endpoint["name"].Value<string>();
var location = endpoint["location"].Value<string>();
switch (name)
{
case "access-token-endpoint":
this.api.AccessTokenEndpoint = location;
break;
case "seb-configuration-endpoint":
this.api.ConfigurationEndpoint = location;
break;
case "seb-handshake-endpoint":
this.api.HandshakeEndpoint = location;
break;
case "seb-log-endpoint":
this.api.LogEndpoint = location;
break;
case "seb-ping-endpoint":
this.api.PingEndpoint = location;
break;
}
}
success = true;
}
if (!success)
{
logger.Error("The selected SEB server instance does not support the required API version!");
}
}
}
catch (Exception e)
{
logger.Error("Failed to parse server API!", e);
}
return success;
}
private bool TryParseConnectionToken(HttpResponseMessage response)
{
try
{
var hasHeader = response.Headers.TryGetValues("SEBConnectionToken", out var values);
if (hasHeader)
{
connectionToken = values.First();
}
else
{
logger.Error("Failed to retrieve connection token!");
}
}
catch (Exception e)
{
logger.Error("Failed to parse connection token!", e);
}
return connectionToken != default(string);
}
private bool TryParseExams(HttpContent content, out IList<Exam> exams)
{
exams = new List<Exam>();
try
{
var json = JsonConvert.DeserializeObject(Extract(content)) as JArray;
foreach (var exam in json.AsJEnumerable())
{
exams.Add(new Exam
{
Id = exam["examId"].Value<string>(),
LmsName = exam["lmsType"].Value<string>(),
Name = exam["name"].Value<string>(),
Url = exam["url"].Value<string>()
});
}
}
catch (Exception e)
{
logger.Error("Failed to parse exams!", e);
}
return exams.Any();
}
private bool TryParseOauth2Token(HttpContent content)
{
try
{
var json = JsonConvert.DeserializeObject(Extract(content)) as JObject;
oauth2Token = json["access_token"].Value<string>();
}
catch (Exception e)
{
logger.Error("Failed to parse Oauth2 token!", e);
}
return oauth2Token != default(string);
}
private bool TryExecute(
HttpMethod method,
string url,
out HttpResponseMessage response,
string content = default(string),
string contentType = default(string),
params (string name, string value)[] headers)
{
response = default(HttpResponseMessage);
for (var attempt = 0; attempt < settings.RequestAttempts && response == default(HttpResponseMessage); attempt++)
{
var request = new HttpRequestMessage(method, url);
if (content != default(string))
{
request.Content = new StringContent(content, Encoding.UTF8);
if (contentType != default(string))
{
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
}
}
foreach (var (name, value) in headers)
{
request.Headers.Add(name, value);
}
try
{
response = httpClient.SendAsync(request).GetAwaiter().GetResult();
logger.Debug($"Completed request: {request.Method} '{request.RequestUri}' -> {ToString(response)}");
}
catch (TaskCanceledException)
{
logger.Debug($"Request {request.Method} '{request.RequestUri}' did not complete within {settings.RequestTimeout}ms!");
break;
}
catch (Exception e)
{
logger.Debug($"Request {request.Method} '{request.RequestUri}' failed due to {e}");
}
}
return response != default(HttpResponseMessage) && response.IsSuccessStatusCode;
}
private bool TrySaveFile(HttpContent content, out Uri uri)
{
uri = new Uri(Path.Combine(appConfig.TemporaryDirectory, $"ServerExam{appConfig.ConfigurationFileExtension}"));
try
{
var task = Task.Run(async () =>
{
return await content.ReadAsStreamAsync();
});
using (var data = task.GetAwaiter().GetResult())
using (var file = new FileStream(uri.LocalPath, FileMode.Create))
{
data.Seek(0, SeekOrigin.Begin);
data.CopyTo(file);
data.Flush();
file.Flush();
}
return true;
}
catch (Exception e)
{
logger.Error($"Failed to save file '{uri.LocalPath}'!", e);
}
return false;
}
private string Extract(HttpContent content)
{
var task = Task.Run(async () =>
{
return await content.ReadAsStreamAsync();
});
var stream = task.GetAwaiter().GetResult();
var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private string ToLogType(LogLevel severity)
{
switch (severity)
{
case LogLevel.Debug:
return "DEBUG_LOG";
case LogLevel.Error:
return "ERROR_LOG";
case LogLevel.Info:
return "INFO_LOG";
case LogLevel.Warning:
return "WARN_LOG";
}
return "UNKNOWN";
}
private string ToString(HttpResponseMessage response)
{
return $"{(int?) response?.StatusCode} {response?.StatusCode} {response?.ReasonPhrase}";
}
}
}