aboutsummaryrefslogtreecommitdiff
path: root/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsService.cs
blob: 5cdcc54f4322e73f33f8aa28ee47baab85fde8b7 (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
using System.Data;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using CrupestApi.Commons;
using Dapper;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;

namespace CrupestApi.Secrets;

public class SecretsService : ISecretsService
{
    private readonly IOptionsSnapshot<CrupestApiConfig> _crupestApiConfig;
    private readonly ILogger<SecretsService> _logger;

    public SecretsService(IOptionsSnapshot<CrupestApiConfig> crupestApiConfig, ILogger<SecretsService> logger)
    {
        _crupestApiConfig = crupestApiConfig;
        _logger = logger;
    }

    private string GetDatabasePath()
    {
        return Path.Combine(_crupestApiConfig.Value.DataDir, "secrets.db");
    }

    private async Task<SqliteConnection> EnsureDatabase()
    {
        var dataSource = GetDatabasePath();
        var connectionStringBuilder = new SqliteConnectionStringBuilder()
        {
            DataSource = dataSource
        };

        if (!File.Exists(dataSource))
        {
            _logger.LogInformation("Data source {0} does not exist. Create one.", dataSource);
            connectionStringBuilder.Mode = SqliteOpenMode.ReadWriteCreate;
            var connectionString = connectionStringBuilder.ToString();
            var connection = new SqliteConnection(connectionString);
            var transaction = await connection.BeginTransactionAsync();

            connection.Execute(@"
CREATE TABLE secrets (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    Key TEXT NOT NULL,
    Secret TEXT NOT NULL,
    Description TEXT NOT NULL,
    ExpireTime TEXT,
    Revoked INTEGER NOT NULL,
    CreateTime TEXT NOT NULL
);

CREATE INDEX secrets_key ON secrets (key);

INSERT INTO secrets (Key, Secret, Description, ExpireTime, Revoked, CreateTime) VALUES (@SecretManagementKey, 'crupest', 'This is the default secret management key.', NULL, 0, @CreateTime);
            ",
            new
            {
                SecretManagementKey = SecretsConstants.SecretManagementKey,
                CreateTime = DateTime.Now.ToString("O"),
            });

            await transaction.CommitAsync();

            _logger.LogInformation("{0} created with 'crupest' as the default secret management value. Please immediate revoke it and create a new one.", dataSource);
            return connection;
        }
        else
        {
            _logger.LogInformation("Data source {0} already exists. Will use it.");
            connectionStringBuilder.Mode = SqliteOpenMode.ReadWrite;
            var connectionString = connectionStringBuilder.ToString();
            return new SqliteConnection(connectionString);
        }
    }

    private string GenerateRandomKey(int length)
    {
        const string alphanum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        using var randomNumberGenerator = RandomNumberGenerator.Create();
        var result = new StringBuilder(length);
        for (int i = 0; i < length; i++)
        {
            result.Append(alphanum[i]);
        }
        return result.ToString();
    }

    private async Task<SecretInfo> GetSecretAsync(IDbConnection dbConnection, string secret)
    {
        var result = await dbConnection.QueryFirstOrDefaultAsync<SecretInfo>(@"
SELECT Id, Key, Secret, Description, ExpireTime, Revoked, CreateTime FROM secrets WHERE Secret = @Secret;
        ", new
        {
            Secret = secret
        });

        return result;

    }

    public async Task<SecretInfo?> GetSecretAsync(string secret)
    {
        using var dbConnection = await EnsureDatabase();
        return await GetSecretAsync(dbConnection, secret);
    }

    public async Task<SecretInfo> CreateSecretAsync(string key, string description, DateTime? expireTime = null)
    {
        var dbConnection = await EnsureDatabase();

        var secret = GenerateRandomKey(16);
        var now = DateTime.Now;

        dbConnection.Execute(@"
INSERT INTO secrets (Key, Secret, Description, ExpireTime, Revoked, CreateTime) VALUES (@Key, @Secret, @Description, @ExpireTime, 0, @CreateTime);
        ",
        new
        {
            Key = key,
            Secret = secret,
            Description = description,
            ExpireTime = expireTime?.ToString("O"),
            CreateTime = now.ToString("O"),
        });

        return new SecretInfo(key, secret, description, expireTime, false, now);
    }

    public async Task<List<SecretInfo>> GetSecretListAsync(bool includeExpired = false, bool includeRevoked = false)
    {
        var dbConnection = await EnsureDatabase();

        var query = await dbConnection.QueryAsync<SecretInfo>(@"
SELECT Key, Secret, Description, ExpireTime, Revoked, CreateTime FROM secrets
WHERE @IncludeExpired OR ExpireTime IS NULL OR ExpireTime > @Now AND
        @IncludeRevoked OR Revoked = 0;
        ", new
        {
            IncludeExpired = includeExpired,
            IncludeRevoked = includeRevoked,
            Now = DateTime.Now.ToString("O"),
        });

        return query.ToList();
    }

    public async Task<List<SecretInfo>> GetSecretListByKeyAsync(string key, bool includeExpired = false, bool includeRevoked = false)
    {
        var dbConnection = await EnsureDatabase();

        var query = await dbConnection.QueryAsync<SecretInfo>(@"
SELECT Key, Secret, Description, ExpireTime, Revoked, CreateTime FROM secrets
WHERE Key = @Key AND
(@IncludeExpired OR ExpireTime IS NULL OR ExpireTime > @Now) AND
(@IncludeRevoked OR Revoked = 0);
        ", new
        {
            Key = key,
            IncludeExpired = includeExpired,
            IncludeRevoked = includeRevoked,
            Now = DateTime.Now.ToString("O"),
        });

        return query.ToList();
    }

    public async Task<SecretInfo> ModifySecretAsync(string secret, SecretModifyRequest modifyRequest)
    {
        var dbConnection = await EnsureDatabase();

        var secretInfo = await GetSecretAsync(dbConnection, secret);

        if (secretInfo is null)
        {
            throw new EntityNotExistException("Secret not found.");
        }

        var queryParams = new DynamicParameters();
        var updateColumnList = new List<string>();

        if (modifyRequest.Key is not null)
        {
            queryParams.Add("Key", modifyRequest.Key);
            updateColumnList.Add("Key");
        }

        if (modifyRequest.Description is not null)
        {
            queryParams.Add("Description", modifyRequest.Description);
            updateColumnList.Add("Description");
        }

        if (modifyRequest.SetExpireTime is true)
        {
            queryParams.Add("ExpireTime", modifyRequest.ExpireTime?.ToString("O"));
            updateColumnList.Add("ExpireTime");
        }

        if (modifyRequest.Revoked is true && secretInfo.Revoked is not true)
        {
            queryParams.Add("Revoked", true);
            updateColumnList.Add("Revoked");
        }

        if (updateColumnList.Count == 0)
        {
            return secretInfo;
        }

        queryParams.Add("Secret", secret);

        var updateColumnString = updateColumnList.GenerateUpdateColumnString();

        var changeCount = await dbConnection.ExecuteAsync($@"
UPDATE secrets SET {updateColumnString} WHERE Secret = @Secret;
        ", queryParams);

        Debug.Assert(changeCount == 1);

        return secretInfo;
    }

    public async Task RevokeSecretAsync(string secret)
    {
        await ModifySecretAsync(secret, new SecretModifyRequest
        {
            Revoked = true,
        });
    }

    public async Task VerifySecretAsync(string? key, string? secret)
    {
        var dbConnection = await EnsureDatabase();

        if (secret is null)
        {
            if (key is not null)
            {
                throw new VerifySecretException(key, "A secret with given key is needed.");
            }
        }

        var entity = await dbConnection.QueryFirstOrDefaultAsync<SecretInfo>(@"
SELECT Id, Key, Secret, Description, ExpireTime, Revoked, CreateTime FROM secrets WHERE Key = @Key AND Secret = @Secret
        ", new
        {
            Key = key,
            Secret = secret,
        });

        if (entity is null)
        {
            throw new VerifySecretException(key, "Secret token is invalid.");
        }

        if (entity.Revoked is true)
        {
            throw new VerifySecretException(key, "Secret token is revoked.");
        }

        if (entity.ExpireTime is not null && DateTime.ParseExact(entity.ExpireTime, "O", null) > DateTime.Now)
        {
            throw new VerifySecretException(key, "Secret token is expired.");
        }

        if (key is not null)
        {
            if (entity.Key != key)
            {
                throw new VerifySecretException(key, "Secret is not for this key", VerifySecretException.ErrorKind.Forbidden);
            }
        }
    }

    public async Task VerifySecretForHttpRequestAsync(HttpRequest request, string? key, string queryKey = "secret")
    {
        string? secret = null;

        var authorizationHeaders = request.Headers.Authorization.ToList();
        if (authorizationHeaders.Count > 1)
        {
            _logger.LogWarning("There are multiple Authorization headers in the request. Will use the last one.");
        }
        if (authorizationHeaders.Count > 0)
        {
            var authorizationHeader = authorizationHeaders[^1] ?? "";
            if (!authorizationHeader.StartsWith("Bearer "))
            {
                throw new VerifySecretException(key, "Authorization header must start with 'Bearer '.");
            }

            secret = authorizationHeader.Substring("Bearer ".Length).Trim();
        }

        var secretQueryParam = request.Query[queryKey].ToList();
        if (secretQueryParam.Count > 1)
        {
            _logger.LogWarning($"There are multiple '{queryKey}' query parameters in the request. Will use the last one.");
        }
        if (secretQueryParam.Count > 0)
        {
            if (secret is not null)
            {
                _logger.LogWarning("Secret found both in Authorization header and query parameter. Will use the one in query parameter.");
            }
            secret = secretQueryParam[^1] ?? "";
        }

        await VerifySecretAsync(key, secret);
    }
}