/// <summary> /// Initializes a new instance of the <see cref="ProtectedUsersAndAuthentication"/> class. /// </summary> /// <param name="accessRepo">Supplied through DI.</param> /// <param name="signUpRepo">Supplied through DI.</param> /// <param name="personRepo">Supplied through DI.</param> /// <param name="env">Supplied through DI.</param> /// <param name="date">Supplied through DI.</param> public ProtectedUsersAndAuthentication( IRepository <AccessTokenSchema> accessRepo, IRepository <SignUpTokenSchema> signUpRepo, IRepository <PersonSchema> personRepo, IAppEnvironment env, IDateExtra date) : base("/") { this.Before += PreSecurity.CheckAccess(accessRepo, date); this.Get <GetUser>("users/{id}/", async(req, res) => { string userID = req.RouteValues.As <string>("id"); var person = await personRepo.FindById(userID); if (person == null || !person.IsExisting) { res.StatusCode = Status404NotFound; return; } // Has to exit due to pre-security check string token = req.Cookies["ExperienceCapture-Access-Token"]; var accessToken = await accessRepo.FindOne( Builders <AccessTokenSchema> .Filter .Where(a => a.Hash == PasswordHasher.Hash(token))); if (person.InternalId != accessToken.User && accessToken.Role != RoleOptions.Admin) { res.StatusCode = Status401Unauthorized; return; } string json = JsonQuery.FulfilEncoding(req.Query, person); if (json != null) { await res.FromJson(json); return; } person.InternalId = null; await res.FromBson(person); }); this.Delete <DeleteUser>("users/{id}/", async(req, res) => { string userID = req.RouteValues.As <string>("id"); var person = await personRepo.FindById(userID); if (person == null) { res.StatusCode = Status404NotFound; return; } // Has to exit due to pre-security check string token = req.Cookies["ExperienceCapture-Access-Token"]; var accessFilter = Builders <AccessTokenSchema> .Filter .Where(a => a.Hash == PasswordHasher.Hash(token)); var accessToken = await accessRepo.FindOne(accessFilter); // Check if the user being requested is the same // As the one requesting, unless they have the admin role if (person.InternalId != accessToken.User && accessToken.Role != RoleOptions.Admin) { res.StatusCode = Status401Unauthorized; return; } var filter = Builders <PersonSchema> .Filter .Where(p => p.Id == userID); var update = Builders <PersonSchema> .Update .Set(p => p.IsExisting, false); await personRepo.Update(filter, update); await res.FromString(); }); this.Post <PostSignUp>("authentication/signUps", async(req, res) => { string newToken = Generate.GetRandomToken(); var tokenDoc = new SignUpTokenSchema { InternalId = ObjectId.GenerateNewId(), Hash = PasswordHasher.Hash(newToken), CreatedAt = new BsonDateTime(date.UtcNow), }; await signUpRepo.Add(tokenDoc); var responce = new SignUpTokenResponce { SignUpToken = newToken, Expiration = TimerExtra.ProjectSeconds(date, tokenDoc.ExpirationSeconds), }; var responceDoc = responce.ToBsonDocument(); string json = JsonQuery.FulfilEncoding(req.Query, responceDoc); if (json != null) { await res.FromJson(json); return; } await res.FromBson(responceDoc); }); }
/// <summary> /// Initializes a new instance of the <see cref="UsersAndAuthentication"/> class. /// </summary> /// <param name="accessRepo">Supplied through DI.</param> /// <param name="signUpRepo">Supplied through DI.</param> /// <param name="claimRepo">Supplied through DI.</param> /// <param name="personRepo">Supplied through DI.</param> /// <param name="env">Supplied through DI.</param> /// <param name="date">Supplied through DI.</param> public UsersAndAuthentication( IRepository <AccessTokenSchema> accessRepo, IRepository <SignUpTokenSchema> signUpRepo, IRepository <ClaimTokenSchema> claimRepo, IRepository <PersonSchema> personRepo, IAppEnvironment env, IDateExtra date) : base("/") { this.Post <PostUsers>("users", async(req, res) => { var newPerson = await req.BindAndValidate <PersonRequest>(); if (!newPerson.ValidationResult.IsValid) { res.StatusCode = Status400BadRequest; return; } var person = await GoogleApi.ValidateUser(newPerson.Data.idToken, env); if (person == null) { res.StatusCode = Status401Unauthorized; return; } var filter = Builders <SignUpTokenSchema> .Filter .Where(t => t.Hash == PasswordHasher.Hash(newPerson.Data.signUpToken)); var signUpDoc = await signUpRepo.FindOne(filter); if (signUpDoc == null || signUpDoc.CreatedAt.IsAfter(date, signUpDoc.ExpirationSeconds)) { res.StatusCode = Status401Unauthorized; return; } if (!signUpDoc.IsExisting) { res.StatusCode = Status404NotFound; return; } var update = Builders <SignUpTokenSchema> .Update .Set(s => s.IsExisting, false); var existingPerson = await personRepo.FindById(person.Subject); // Check if the person already exists if (existingPerson != null) { // Recreate them if they where deleted if (!existingPerson.IsExisting) { var filterPerson = Builders <PersonSchema> .Filter .Where(p => p.Id == person.Subject); var updatePerson = Builders <PersonSchema> .Update .Set(p => p.IsExisting, true); await personRepo.Update(filterPerson, updatePerson); await signUpRepo.Update(filter, update); await res.FromString(); } else { // Return error is they really exist res.StatusCode = Status409Conflict; } return; } var personObject = new PersonSchema { InternalId = ObjectId.GenerateNewId(), Id = person.Subject, Fullname = person.Name, Firstname = person.GivenName, Lastname = person.FamilyName, Email = person.Email, CreatedAt = new BsonDateTime(date.UtcNow), Role = signUpDoc.Role, }; await personRepo.Add(personObject); await signUpRepo.Update(filter, update); await res.FromString(); }); // Due to a bug with adding multiple routes of the same path // Path in different modules, we manual auth here this.Get <GetUsers>("users", async(req, res) => { var isAllowed = await PreSecurity.CheckAccessDirectly(req, res, accessRepo, date, RoleOptions.Admin); if (!isAllowed) { return; } var filter = Builders <PersonSchema> .Filter .Where(p => p.IsExisting == true); var sorter = Builders <PersonSchema> .Sort .Descending(p => p.Fullname); var persons = await personRepo.FindAll(filter, sorter); var personsWithoutId = persons.Select((p) => { p.InternalId = null; return(p); }); var responceBody = new PersonsResponce { ContentList = new List <PersonSchema>(personsWithoutId), }; string json = JsonQuery.FulfilEncoding(req.Query, responceBody); if (json != null) { await res.FromJson(json); return; } await res.FromBson(responceBody); }); this.Post <PostAccessToken>("users/{id}/tokens/", async(req, res) => { string userID = req.RouteValues.As <string>("id"); var userDoc = await personRepo.FindById(userID); if (userDoc == null || !userDoc.IsExisting) { res.StatusCode = Status404NotFound; return; } var newAccessRequest = await req.BindAndValidate <AccessTokenRequest>(); if (!newAccessRequest.ValidationResult.IsValid) { res.StatusCode = Status400BadRequest; return; } var person = await GoogleApi.ValidateUser(newAccessRequest.Data.idToken, env); if (person == null) { res.StatusCode = Status401Unauthorized; return; } if (person.Subject != userID) { res.StatusCode = Status409Conflict; return; } string newToken = Generate.GetRandomToken(); string newHash = PasswordHasher.Hash(newToken); var tokenObject = new AccessTokenSchema { InternalId = ObjectId.GenerateNewId(), Hash = newHash, User = userDoc.InternalId, CreatedAt = new BsonDateTime(date.UtcNow), Role = userDoc.Role, }; await accessRepo.Add(tokenObject); if (newAccessRequest.Data.claimToken != null) { var filterClaims = Builders <ClaimTokenSchema> .Filter .Where(c => c.Hash == PasswordHasher.Hash(newAccessRequest.Data.claimToken)); var claimDoc = await claimRepo.FindOne(filterClaims); // 401 returned twice, which may be hard for the client to interpret if (claimDoc == null || claimDoc.CreatedAt.IsAfter(date, claimDoc.ExpirationSeconds)) { res.StatusCode = Status401Unauthorized; return; } // Don't allow overwriting an access token if (claimDoc.Access == null && claimDoc.IsExisting) { var update = Builders <ClaimTokenSchema> .Update .Set(c => c.Access, tokenObject.InternalId) #pragma warning disable SA1515 // Unfortunately claim access tokens have to be saved to the database // So that state can be communicated between clients #pragma warning restore SA1515 .Set(c => c.AccessToken, newToken); await claimRepo.Update(filterClaims, update); } await res.FromString(); } else { var responce = new AccessTokenResponce { AccessToken = newToken, Expiration = TimerExtra.ProjectSeconds(date, tokenObject.ExpirationSeconds), }; string json = JsonQuery.FulfilEncoding(req.Query, responce); if (json != null) { await res.FromJson(json); return; } await res.FromBson(responce.ToBsonDocument()); } }); this.Post <PostClaims>("authentication/claims/", async(req, res) => { string newToken = Generate.GetRandomToken(); string newHash = PasswordHasher.Hash(newToken); var tokenDoc = new ClaimTokenSchema { InternalId = ObjectId.GenerateNewId(), Hash = newHash, CreatedAt = new BsonDateTime(date.UtcNow), }; await claimRepo.Add(tokenDoc); var responce = new ClaimTokenResponce { ClaimToken = newToken, Expiration = TimerExtra.ProjectSeconds(date, tokenDoc.ExpirationSeconds), }; string json = JsonQuery.FulfilEncoding(req.Query, responce); if (json != null) { await res.FromJson(json); return; } await res.FromBson(responce.ToBsonDocument()); }); this.Get <GetClaims>("authentication/claims/", async(req, res) => { string claimToken = req.Cookies["ExperienceCapture-Claim-Token"]; if (claimToken == null) { res.StatusCode = Status400BadRequest; return; } var filter = Builders <ClaimTokenSchema> .Filter .Where(c => c.Hash == PasswordHasher.Hash(claimToken)); var claimDoc = await claimRepo.FindOne(filter); if (claimDoc == null) { res.StatusCode = Status404NotFound; return; } if (!claimDoc.IsExisting || claimDoc.CreatedAt.IsAfter(date, claimDoc.ExpirationSeconds)) { res.StatusCode = Status404NotFound; return; } if (claimDoc.Access == null) { res.StatusCode = Status202Accepted; await res.FromString("PENDING"); return; } var update = Builders <ClaimTokenSchema> .Update .Set(c => c.IsExisting, false) #pragma warning disable SA1515 // Removes the access token from the database // Important to increase security #pragma warning restore SA1515 .Set(c => c.AccessToken, null); await claimRepo.Update(filter, update); var responce = new AccessTokenResponce { AccessToken = claimDoc.AccessToken, Expiration = TimerExtra.ProjectSeconds(date, claimDoc.ExpirationSeconds), }; string json = JsonQuery.FulfilEncoding(req.Query, responce); if (json != null) { await res.FromString(json); return; } await res.FromBson(responce.ToBsonDocument()); }); this.Post <PostAdmin>("authentication/admins/", async(req, res) => { var newAdmin = await req.BindAndValidate <AdminPasswordRequest>(); if (!newAdmin.ValidationResult.IsValid) { res.StatusCode = Status400BadRequest; return; } if (!PasswordHasher.Check(newAdmin.Data.password, env.PasswordHash) && env.SkipValidation != "true") { res.StatusCode = Status401Unauthorized; return; } string newToken = Generate.GetRandomToken(); var tokenDoc = new SignUpTokenSchema { InternalId = ObjectId.GenerateNewId(), Hash = PasswordHasher.Hash(newToken), CreatedAt = new BsonDateTime(date.UtcNow), Role = RoleOptions.Admin, }; await signUpRepo.Add(tokenDoc); var responce = new SignUpTokenResponce { SignUpToken = newToken, Expiration = TimerExtra.ProjectSeconds(date, tokenDoc.ExpirationSeconds), }; string json = JsonQuery.FulfilEncoding(req.Query, responce); if (json != null) { await res.FromJson(json); return; } await res.FromBson(responce.ToBsonDocument()); }); }
/// <summary> /// Initializes a new instance of the <see cref="Sessions"/> class. /// </summary> /// <param name="accessRepo">Supplied through DI.</param> /// <param name="sessionRepo">Supplied through DI.</param> /// <param name="personRepo">Supplied through DI.</param> /// <param name="captureRepo">Supplied through DI.</param> /// <param name="logger">Supplied through DI.</param> /// <param name="date">Supplied through DI.</param> public Sessions( IRepository <AccessTokenSchema> accessRepo, IRepository <SessionSchema> sessionRepo, IRepository <PersonSchema> personRepo, IRepository <BsonDocument> captureRepo, ILogger logger, IDateExtra date) : base("/sessions") { this.Before += PreSecurity.CheckAccess(accessRepo, date); this.Post <PostSessions>("/", async(req, res) => { // Has to exist due to PreSecurity Check string token = req.Cookies["ExperienceCapture-Access-Token"]; var accessTokenDoc = await accessRepo.FindOne( Builders <AccessTokenSchema> .Filter .Where(a => a.Hash == PasswordHasher.Hash(token))); var filterUser = Builders <PersonSchema> .Filter.Where(p => p.InternalId == accessTokenDoc.User); var user = await personRepo .FindOne(filterUser); string shortID = Generate.GetRandomId(4); var sessionDoc = new SessionSchema { InternalId = ObjectId.GenerateNewId(), Id = shortID, User = user, // Copying user data instead of referencing so it can never change in the session CreatedAt = new BsonDateTime(date.UtcNow), Tags = new List <string>(), }; // Retry generating a short id until it is unique bool isUnique = true; do { try { sessionDoc.Id = shortID; await sessionRepo.Add(sessionDoc); isUnique = true; } catch (MongoWriteException e) { // Re-throw any other type of exception except non-unique keys if (e.WriteError.Code != 11000) { throw e; } shortID = Generate.GetRandomId(4); isUnique = false; } }while (!isUnique); // isOngoing is a proxy variable and will always start out as true sessionDoc.IsOngoing = true; sessionDoc.InternalId = null; sessionDoc.User.InternalId = null; captureRepo.Configure($"sessions.{shortID}"); // Secondary index or else Mongo will fail on large queries // It has a limit for max number of documents on properties // Without an index, see https://docs.mongodb.com/manual/reference/limits/#Sort-Operations var index = Builders <BsonDocument> .IndexKeys; var key = index.Ascending("frameInfo.realtimeSinceStartup"); await captureRepo.Index(key); string json = JsonQuery.FulfilEncoding(req.Query, sessionDoc); if (json != null) { await res.FromJson(json); return; } await res.FromBson(sessionDoc); }); this.Get <GetSessions>("/", async(req, res) => { var builder = Builders <SessionSchema> .Filter; // Note: only use `&=` for adding to the filter, // Or else the filter cannot handle multiple query string options FilterDefinition <SessionSchema> filter = builder.Empty; var startMin = new BsonDateTime(date.UtcNow.AddSeconds(-300)); // 5 minutes var closeMin = new BsonDateTime(date.UtcNow.AddSeconds(-5)); // 5 seconds var hasTags = req.Query.AsMultiple <string>("hasTags").ToList(); if (hasTags.Count > 0) { foreach (var tag in hasTags) { filter &= builder.Where(s => s.Tags.Contains(tag)); } } var lacksTags = req.Query.AsMultiple <string>("lacksTags").ToList(); if (lacksTags.Count > 0) { foreach (var tag in lacksTags) { filter &= builder.Where(s => !s.Tags.Contains(tag)); } } // Three potential options: null, true, or false if (req.Query.As <bool?>("isOngoing") != null) { bool isOngoing = req.Query.As <bool>("isOngoing"); if (isOngoing) { filter &= builder.Where(s => s.IsOpen == true) & ((builder.Eq(s => s.LastCaptureAt, BsonNull.Value) & builder.Where(s => s.CreatedAt > startMin)) | (builder.Eq(s => s.LastCaptureAt, BsonNull.Value) & builder.Where(s => s.LastCaptureAt > closeMin))); } else { filter &= builder.Where(s => s.IsOpen == false) | ((builder.Eq(s => s.LastCaptureAt, BsonNull.Value) & builder.Where(s => s.CreatedAt < startMin)) | (builder.Eq(s => s.LastCaptureAt, BsonNull.Value) & builder.Where(s => s.LastCaptureAt < closeMin))); } } var page = req.Query.As <int?>("page") ?? 1; if (page < 1) { // Page query needs to be possible res.StatusCode = Status400BadRequest; return; } var direction = req.Query.As <string>("sort"); SortDefinition <SessionSchema> sorter; if (direction == null) { sorter = Builders <SessionSchema> .Sort.Descending(s => s.CreatedAt); } else { if (Enum.TryParse(typeof(SortOptions), direction, true, out object options)) { sorter = ((SortOptions)options).ToDefinition(); } else { res.StatusCode = Status400BadRequest; return; } } var sessionDocs = await sessionRepo .FindAll(filter, sorter, page); var sessionsDocsWithOngoing = sessionDocs.Select((s) => { bool isStarted = false; if (s.LastCaptureAt != BsonNull.Value) { isStarted = true; } bool isOngoing; if (s.IsOpen) { isOngoing = (!isStarted && startMin < s.CreatedAt) || (isStarted && closeMin < s.LastCaptureAt); } else { isOngoing = false; } s.IsOngoing = isOngoing; s.InternalId = null; s.User.InternalId = null; return(s); }); var count = await sessionRepo.FindThenCount(filter); var clientValues = new SessionsResponce { // Bson documents can't start with an array like Json, so a wrapping object is used instead ContentList = sessionsDocsWithOngoing.ToList(), PageTotal = (long)Math.Ceiling((double)count / 10d), }; string json = JsonQuery.FulfilEncoding(req.Query, clientValues); if (json != null) { await res.FromJson(json); return; } await res.FromBson(clientValues); }); this.Post <PostSession>("/{id}", async(req, res) => { string shortID = req.RouteValues.As <string>("id"); var sessionDoc = await sessionRepo .FindById(shortID); if (sessionDoc == null) { res.StatusCode = Status404NotFound; return; } if (!sessionDoc.IsOpen) { res.StatusCode = Status400BadRequest; return; } BsonDocument document; if (JsonQuery.CheckDecoding(req.Query)) { using (var ms = new MemoryStream()) { await req.Body.CopyToAsync(ms); ms.Position = 0; try { document = BsonSerializer.Deserialize <BsonDocument>(ms); } catch (Exception err) { logger.LogError(err.Message); res.StatusCode = Status400BadRequest; return; } } } else { string json = await req.Body.AsStringAsync(); try { document = BsonDocument.Parse(json); } catch (Exception err) { logger.LogError(err.Message); res.StatusCode = Status400BadRequest; return; } } // Manual validation, because Fluent Validation would remove extra properties if (!document.Contains("frameInfo") || document["frameInfo"].BsonType != BsonType.Document || !document["frameInfo"].AsBsonDocument.Contains("realtimeSinceStartup") || document["frameInfo"]["realtimeSinceStartup"].BsonType != BsonType.Double) { res.StatusCode = Status400BadRequest; return; } captureRepo.Configure($"sessions.{shortID}"); await captureRepo.Add(document); var filter = Builders <SessionSchema> .Filter.Where(s => s.Id == shortID); // This lastCaptureAt is undefined on the session document until the first call of this endpoint // Export flags are reset so the session can be re-exported var update = Builders <SessionSchema> .Update .Set(s => s.LastCaptureAt, new BsonDateTime(date.UtcNow)) .Set(s => s.ExportState, ExportOptions.NotStarted); await sessionRepo.Update(filter, update); await res.FromString(); }); this.Get <GetSession>("/{id}", async(req, res) => { string shortID = req.RouteValues.As <string>("id"); var sessionDoc = await sessionRepo .FindById(shortID); if (sessionDoc == null) { res.StatusCode = Status404NotFound; return; } var startRange = new BsonDateTime(date.UtcNow.AddSeconds(-300)); // 5 minutes var closeRange = new BsonDateTime(date.UtcNow.AddSeconds(-5)); // 5 seconds bool isStarted = false; // Check if key exists if (sessionDoc.LastCaptureAt != BsonNull.Value) { isStarted = true; } bool isOngoing; if (sessionDoc.IsOpen) { isOngoing = (!isStarted && startRange.CompareTo(sessionDoc.CreatedAt) < 0) || (isStarted && closeRange.CompareTo(sessionDoc.LastCaptureAt) < 0); } else { isOngoing = false; } sessionDoc.IsOngoing = isOngoing; sessionDoc.InternalId = null; sessionDoc.User.InternalId = null; string json = JsonQuery.FulfilEncoding(req.Query, sessionDoc); if (json != null) { await res.FromJson(json); return; } await res.FromBson(sessionDoc); }); this.Delete <DeleteSession>("/{id}", async(req, res) => { string shortID = req.RouteValues.As <string>("id"); var filter = Builders <SessionSchema> .Filter.Where(s => s.Id == shortID); var sessionDoc = await sessionRepo .FindById(shortID); if (sessionDoc == null) { res.StatusCode = Status404NotFound; return; } var update = Builders <SessionSchema> .Update .Set(s => s.IsOpen, false); await sessionRepo.Update(filter, update); await res.FromString(); }); }