aboutsummaryrefslogtreecommitdiff
path: root/Plugins/OAuth2ClientApplications/src/Endpoints/ApplicationEndpoint.cs
blob: 786fb7fa357699fbe4ffd656c050b409e3ed7d7d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
/*
* Copyright (c) 2024 Vaughn Nugent
* 
* Library: VNLib
* Package: OAuth2ClientApplications
* File: ApplicationEndpoint.cs 
*
* ApplicationEndpoint.cs is part of OAuth2ClientApplications which is part of the larger 
* VNLib collection of libraries and utilities.
*
* OAuth2ClientApplications is free software: you can redistribute it and/or modify 
* it under the terms of the GNU Affero General Public License as 
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* OAuth2ClientApplications is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program.  If not, see https://www.gnu.org/licenses/.
*/

using System;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Text.Json.Serialization;

using VNLib.Utils.Memory;
using VNLib.Utils.Logging;
using VNLib.Utils.Extensions;
using VNLib.Plugins;
using VNLib.Plugins.Essentials;
using VNLib.Plugins.Essentials.Accounts;
using VNLib.Plugins.Essentials.Endpoints;
using VNLib.Plugins.Essentials.Extensions;
using VNLib.Plugins.Essentials.Oauth.Applications;
using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Sql;
using VNLib.Plugins.Extensions.Loading.Routing;
using VNLib.Plugins.Extensions.Data.Extensions;
using VNLib.Plugins.Essentials.Users;
using VNLib.Plugins.Extensions.Loading.Users;
using static VNLib.Plugins.Essentials.Statics;


namespace OAuth2ClientApplications.Endpoints
{

    [EndpointPath("{{path}}")]
    [EndpointLogName("Applications")]
    [ConfigurationName("applications")]
    internal sealed class ApplicationEndpoint : ProtectedWebEndpoint
    {
       
        private readonly ApplicationStore Applications;
        private readonly int MaxAppsPerUser;
        private readonly string MaxAppOverloadMessage;
        private readonly IUserManager Users;

        private static readonly UserAppValidator Validator = new();

        public ApplicationEndpoint(PluginBase plugin, IConfigScope config)
        {
            MaxAppsPerUser = config.GetRequiredProperty<int>("max_apps_per_user");

            Applications = new(
                conextOptions: plugin.GetContextOptions(), 
                secretHashing: plugin.GetOrCreateSingleton<ManagedPasswordHashing>()
            );

            Users = plugin.GetOrCreateSingleton<UserManager>();

            //Complie overload message
            MaxAppOverloadMessage = $"You have reached the limit of {MaxAppsPerUser} applications, this application cannot be created";
        }

        protected override async ValueTask<VfReturnType> GetAsync(HttpEntity ev)
        {
            //Try to get a single application from the database

            //Get a single specific application from an appid
            if (ev.QueryArgs.TryGetNonEmptyValue("Id", out string? appid))
            {
                appid = ValidatorExtensions.OnlyAlphaRegx.Replace(appid, string.Empty);

                //Execute get single app
                UserApplication? singeApp = await Applications.GetSingleAsync(appid, ev.Session.UserID);

                return singeApp == null ? VfReturnType.NotFound : VirtualOkJson(ev, singeApp);
            }
            //Process a "get all" 
            else
            {
                //Create list to store all applications
                List<UserApplication> applications = Applications.ListRental.Rent();
                try
                {
                    //Get all applications to fill the list
                    _ = await Applications.GetCollectionAsync(applications, ev.Session.UserID, MaxAppsPerUser, ev.EventCancellation);
                    //Write response (will convert json as needed before releasing the list)
                    return VirtualOkJson(ev, applications);
                }
                finally
                {
                    //Return the list
                    Applications.ListRental.Return(applications);
                }
            }
        }
        
        protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
        {
            //Default response
            WebMessage webm = new();
            //Oauth is only available for local accounts
            if (!entity.Session.HasLocalAccount())
            {
                webm.Result = "OAuth is only available for internal user accounts";
                return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
            }

            if (entity.QueryArgs.IsArgumentSet("action", "create"))
            {
                return await CreateAppAsync(entity);
            }
            
            //Update the application secret
            else if (entity.QueryArgs.IsArgumentSet("action", "secret"))
            {
                using JsonDocument? update = await entity.GetJsonFromFileAsync();
                
                if(webm.Assert(update != null, "Invalid request"))
                {
                    return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
                }

                //Update message will include a challenge and an app id
                string? appId = update.RootElement.GetPropString("Id");
                
                if (webm.Assert(!string.IsNullOrWhiteSpace(appId), "Application with the specified id does not exist"))
                {
                    return VirtualClose(entity, webm, HttpStatusCode.NotFound);
                }

                //validate the user's password
                if (await ValidateUserPassword(entity, update, webm) == false)
                {
                    return VirtualClose(entity, webm, HttpStatusCode.Unauthorized);
                }

                //Update the app's secret
                using PrivateString? secret = await Applications.UpdateSecretAsync(entity.Session.UserID, appId, entity.EventCancellation);
                
                if (webm.Assert(secret != null, "Failed to update the application secret"))
                {
                    return VirtualClose(entity, webm, HttpStatusCode.InternalServerError);
                }
                
                /*
                 * We must return the secret to the user.
                 * 
                 * The PrivateString must be casted and serialized
                 * while the using statment is in scope
                 */
                ApplicationMessage result = new()
                {
                    ApplicationID = appId,
                    //Send raw secret
                    RawSecret = (string?)secret
                };

                //Must write response while password is in scope
                return VirtualOkJson(entity, result);
            }
            else if (entity.QueryArgs.IsArgumentSet("action", "delete"))
            {
                using JsonDocument? update = await entity.GetJsonFromFileAsync();
                
                if(webm.Assert(update != null, "Invalid request"))
                {
                    return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
                }
                
                //Update message will include a challenge and an app id
                string? appId = update.RootElement.GetPropString("Id");

                if (string.IsNullOrWhiteSpace(appId))
                {
                    return VfReturnType.NotFound;
                }

                //validate the password
                if(await ValidateUserPassword(entity, update, webm) == false)
                {
                    return VirtualClose(entity, webm, HttpStatusCode.Unauthorized);
                }            

                //Try to delete the app
                if (await Applications.DeleteAsync(appId, entity.Session.UserID))
                {
                    return VirtualClose(entity, HttpStatusCode.NoContent);
                }
            }
            else
            {
                webm.Result = "The update type specified is not defined";
                return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
            }
            return VfReturnType.BadRequest;
        }
        
        protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity)
        {
            ValErrWebMessage webm = new();
            
            //Oauth is only available for local accounts
            if (!entity.Session.HasLocalAccount())
            {
                webm.Result = "OAuth is only available for internal user accounts";
                return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
            }
            
            //Get the application from client
            UserApplication? app = await entity.GetJsonFromFileAsync<UserApplication>(SR_OPTIONS);
            
            if (webm.Assert(app != null, "Application is empty"))
            {
                return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
            }
            
            //set user-id 
            app.UserId = entity.Session.UserID;
            //remove permissions
            app.Permissions = null;
            
            //perform validation on the application update (should remove unused fields)
            if (!Validator.Validate(app, webm))
            {
                return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
            }
            
            //Update the app's meta
            if (await Applications.UpdateAsync(app))
            {
                //Send the app to the client
                return VirtualClose(entity, HttpStatusCode.NoContent);
            }
            
            //The app was not found and could not be updated
            return VfReturnType.NotFound;
        }

        private async ValueTask<VfReturnType> CreateAppAsync(HttpEntity entity)
        {
            ValErrWebMessage webm = new();

            //Get the application from client
            UserApplication? newApp = await entity.GetJsonFromFileAsync<UserApplication>(SR_OPTIONS);
          
            if (webm.Assert(newApp != null, "Application is empty"))
            {
                return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
            }
            
            //Validate the new application
            if (!Validator.Validate(newApp, webm))
            {
                return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
            }

            //If no premissions are specified, set to "none"
            if (string.IsNullOrWhiteSpace(newApp.Permissions))
            {
                newApp.Permissions = "none";
            }
            
            //See if the user has enough room for more apps
            long appCount = await Applications.GetCountAsync(entity.Session.UserID, entity.EventCancellation);
            
            if (appCount == -1)
            {
                webm.Result = $"There was a server error during creation of your application";
                Log.Error("There was an error retreiving the number of applications for user {id}", entity.Session.UserID);
                return VirtualClose(entity, webm, HttpStatusCode.InternalServerError);
            }
            if (webm.Assert(appCount < MaxAppsPerUser, MaxAppOverloadMessage))
            {
                return VirtualOk(entity, webm);
            }
            
            //Parse permission string an re-build it to clean it up
            newApp.Permissions = ParsePermissions(newApp.Permissions);

            //Set user-id
            newApp.UserId = entity.Session.UserID;

            //Create the new application
            if (!await Applications.CreateAppAsync(newApp))
            {
                webm.Result = "The was an issue creating your application";
                entity.CloseResponse(webm);
                return VfReturnType.VirtualSkip;
            }

            //Make sure to dispose the secret
            using PrivateString secret = newApp.RawSecret!;
            
            //Success, now respond to the client with the new app information
            ApplicationMessage mess = new()
            {
                ApplicationID = newApp.Id,
                ApplicationName = newApp.AppName,
                RawSecret = (string)secret,
                ClientID = newApp.ClientId,
                Description = newApp.AppDescription,
                CreatedTime = newApp.Created.ToString("O"),
                LastUpdatedTime = newApp.LastModified.ToString("O")
            };

            //Must write response while the secret is in scope
            entity.CloseResponseJson(HttpStatusCode.Created, mess);
            return VfReturnType.VirtualSkip;
        }

        private async Task<bool> ValidateUserPassword(HttpEntity entity, JsonDocument request, WebMessage webm)
        {
            //Get password from request and capture it as a private string
            using PrivateString? rawPassword = PrivateString.ToPrivateString(request.RootElement.GetPropString("password"), true);

            if (webm.Assert(rawPassword != null, "Please enter your account password"))
            {
                //Must sent a 401 to indicate that the password is required
                return false;
            }

            //Get the current user from the store
            using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation);

            if (webm.Assert(user != null, "Please check your password"))
            {
                return false;
            }

            //Validate the password against the user
            bool isPasswordValid = await Users.ValidatePasswordAsync(user, rawPassword, PassValidateFlags.None, entity.EventCancellation) == UserPassValResult.Success;

            if (webm.Assert(isPasswordValid, "Please check your password"))
            {
                return false;
            }

            return true;
        }

        private static string ParsePermissions(string permissions)
        {
            StringBuilder builder = new();
            //Local function for splitting permissions
            static void SplitCb(ReadOnlySpan<char> permission, StringBuilder builder)
            {
                builder.Append(permission);
                builder.Append(',');
            }
            //Split permissions at comma and clean up the entires
            permissions.AsSpan().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries, SplitCb, builder);
            //return the string
            return builder.ToString();
        }

        private sealed class ApplicationMessage
        {
            [JsonPropertyName("name")]
            public string? ApplicationName { get; set; }

            [JsonPropertyName("description")]
            public string? Description { get; set; }

            [JsonPropertyName("client_id")]
            public string? ClientID { get; set; }

            [JsonPropertyName("raw_secret")]
            public string? RawSecret { get; set; }

            [JsonPropertyName("Id")]
            public string? ApplicationID { get; set; }

            [JsonPropertyName("permissions")]
            public string? Permissions { get; set; }

            [JsonPropertyName("Created")]
            public string? CreatedTime { get; set; }

            [JsonPropertyName("LastModified")]
            public string? LastUpdatedTime { get; set; }
        }

    }
}