Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

Commit

Permalink
Add API key type for accessing statistics
Browse files Browse the repository at this point in the history
  • Loading branch information
sethvargo committed Dec 17, 2020
1 parent 80123e4 commit 9494991
Show file tree
Hide file tree
Showing 10 changed files with 165 additions and 142 deletions.
3 changes: 2 additions & 1 deletion cmd/server/assets/apikeys/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@
{{.APIKeyPreview}}
</td>
<td class="text-center">
{{if .IsAdminType}}<span class="badge badge-pill badge-primary" data-toggle="tooltip" title="Can be used to issue verification codes">Admin</span>{{end}}
{{if .IsAdminType}}<span class="badge badge-pill badge-primary" data-toggle="tooltip" title="For issuing verification codes">Admin</span>{{end}}
{{if .IsDeviceType}}<span class="badge badge-pill badge-secondary" data-toggle="tooltip" title="For use in mobile apps to verify codes and get certificates">Device</span>{{end}}
{{if .IsStatsType}}<span class="badge badge-pill badge-secondary" data-toggle="tooltip" title="For retrieving realm statistics">Stats</span>{{end}}
</td>
{{if $canWrite}}
<td class="text-center">
Expand Down
17 changes: 5 additions & 12 deletions cmd/server/assets/apikeys/new.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,18 @@ <h1>New API key</h1>
<div class="form-label-group">
<input type="text" id="name" name="name" class="form-control{{if $authApp.ErrorsFor "name"}} is-invalid{{end}}" value="{{$authApp.Name}}" placeholder="Application name" autofocus>
<label for="name">Application name</label>
{{if $authApp.ErrorsFor "name"}}
<div class="invalid-feedback">
{{joinStrings ($authApp.ErrorsFor "name") ", "}}
</div>
{{end}}
{{template "errorable" $authApp.ErrorsFor "name"}}
</div>

<div class="form-group">
<input type="hidden" name="type" value="-1">
<select class="form-control{{if $authApp.ErrorsFor "type"}} is-invalid{{end}}" name="type" id="type">
<option selected disabled>Select type...</option>
<option value="{{.typeDevice}}" {{if (eq $authApp.APIKeyType .typeDevice)}}selected{{end}}>Device (can verify codes)</option>
<option value="{{.typeAdmin}}" {{if (eq $authApp.APIKeyType .typeAdmin)}}selected{{end}}>Admin (can issue codes)</option>
<option value="{{.typeDevice}}" {{selectedIf (eq $authApp.APIKeyType .typeDevice)}}>Device (can verify codes)</option>
<option value="{{.typeAdmin}}" {{selectedIf (eq $authApp.APIKeyType .typeAdmin)}}>Admin (can issue codes)</option>
<option value="{{.typeStats}}" {{selectedIf (eq $authApp.APIKeyType .typeStats)}}>Stats (can view statistics)</option>
</select>
{{if $authApp.ErrorsFor "type"}}
<div class="invalid-feedback">
{{joinStrings ($authApp.ErrorsFor "type") ", "}}
</div>
{{end}}
{{template "errorable" $authApp.ErrorsFor "type"}}
</div>

<button type="submit" id="submit" class="btn btn-primary btn-block">Create API key</button>
Expand Down
216 changes: 110 additions & 106 deletions cmd/server/assets/apikeys/show.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,17 @@ <h1>{{$authApp.Name}} API key</h1>
</p>

{{if $apiKey}}
<div class="card mb-3 shadow-sm">
<div class="card-header">API key</div>
<div class="card-body">
<div class="alert alert-danger" role="alert">
This is your API key - it will only be displayed once. <strong>You
must securely save this API key elsewhere!</strong>
</div>
<div class="card mb-3 shadow-sm">
<div class="card-header">API key</div>
<div class="card-body">
<div class="alert alert-danger" role="alert">
This is your API key - it will only be displayed once. <strong>You
must securely save this API key elsewhere!</strong>
</div>

<textarea id="apikey-value" class="form-control" rows="4" readonly>{{$apiKey}}</textarea>
<textarea id="apikey-value" class="form-control" rows="4" readonly>{{$apiKey}}</textarea>
</div>
</div>
</div>
{{end}}

<div class="card mb-3 shadow-sm">
Expand All @@ -61,118 +61,122 @@ <h1>{{$authApp.Name}} API key</h1>
Device (can verify codes)
{{else if $authApp.IsAdminType}}
Admin (can issue codes)
{{else if $authApp.IsStatsType}}
Stats (can view stats)
{{else}}
Unknown
{{end}}
</div>
</div>
</div>

<div class="card mb-3 shadow-sm">
<div class="card-header">
<span class="oi oi-bar-chart mr-2 ml-n1"></span>
Statistics
</div>
<div class="card-body">
<div id="apikey_stats_chart" class="container d-flex h-100 w-100" style="min-height:300px;">
<p class="justify-content-center align-self-center text-center font-italic w-100">Loading chart...</p>
{{if $authApp.IsAdminType}}
<div class="card mb-3 shadow-sm">
<div class="card-header">
<span class="oi oi-bar-chart mr-2 ml-n1"></span>
Statistics
</div>
<div class="card-body">
<div id="apikey_stats_chart" class="container d-flex h-100 w-100" style="min-height:300px;">
<p class="justify-content-center align-self-center text-center font-italic w-100">Loading chart...</p>
</div>
</div>
<small class="card-footer d-flex justify-content-between text-muted">
<span>
This data is refreshed every 30 minutes.
<a href="#" data-toggle="modal" data-target="#apikey-stats-modal">Learn more</a>
</span>
<span>
<span class="mr-1">Export as:</span>
<a href="/realm/apikeys/{{$authApp.ID}}/stats.csv" class="mr-1">CSV</a>
<a href="/realm/apikeys/{{$authApp.ID}}/stats.json">JSON</a>
</span>
</small>
</div>
<small class="card-footer d-flex justify-content-between text-muted">
<span>
This data is refreshed every 30 minutes.
<a href="#" data-toggle="modal" data-target="#apikey-stats-modal">Learn more</a>
</span>
<span>
<span class="mr-1">Export as:</span>
<a href="/realm/apikeys/{{$authApp.ID}}/stats.csv" class="mr-1">CSV</a>
<a href="/realm/apikeys/{{$authApp.ID}}/stats.json">JSON</a>
</span>
</small>
</div>

<div class="modal fade" id="apikey-stats-modal" data-backdrop="static" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Codes issued by {{$authApp.Name}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body mb-n3">
<p>
This chart reflects the number of codes issued by
{{$authApp.Name}}.
</p>
<p>
This chart does <u>not</u> include codes that were issued by users
via the UI or codes that were issued using a different API key.
</p>
<div class="modal fade" id="apikey-stats-modal" data-backdrop="static" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Codes issued by {{$authApp.Name}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body mb-n3">
<p>
This chart reflects the number of codes issued by
{{$authApp.Name}}.
</p>
<p>
This chart does <u>not</u> include codes that were issued by users
via the UI or codes that were issued using a different API key.
</p>
</div>
</div>
</div>
</div>
</div>
</main>

<script src="https://www.gstatic.com/charts/loader.js"></script>
<script>
let userStatsChartDiv = document.getElementById('apikey_stats_chart');
let dateFormatter;

google.charts.load('current', {
packages: ['corechart'],
callback: function() {
dateFormatter = new google.visualization.DateFormat({
pattern: 'MMM dd',
});
</main>

<script src="https://www.gstatic.com/charts/loader.js"></script>
<script>
let userStatsChartDiv = document.getElementById('apikey_stats_chart');
let dateFormatter;

google.charts.load('current', {
packages: ['corechart'],
callback: function() {
dateFormatter = new google.visualization.DateFormat({
pattern: 'MMM dd',
});

drawStats();
},
});

drawStats();
},
});

function drawStats() {
$.ajax({
url: '/realm/apikeys/{{$authApp.ID}}/stats.json',
dataType: 'json',
})
.done(function(data, status, xhr) {
if (!data.statistics) {
$(userStatsChartDiv).find('p').text('This API key has not yet issued any codes.');
return;
}

var dataTable = new google.visualization.DataTable();
dataTable.addColumn('date', 'Date');
dataTable.addColumn('number', 'Issued');

data.statistics.reverse().forEach(function(row) {
dataTable.addRow([utcDate(row.date), row.data.codes_issued]);
function drawStats() {
$.ajax({
url: '/realm/apikeys/{{$authApp.ID}}/stats.json',
dataType: 'json',
})
.done(function(data, status, xhr) {
if (!data.statistics) {
$(userStatsChartDiv).find('p').text('This API key has not yet issued any codes.');
return;
}

var dataTable = new google.visualization.DataTable();
dataTable.addColumn('date', 'Date');
dataTable.addColumn('number', 'Issued');

data.statistics.reverse().forEach(function(row) {
dataTable.addRow([utcDate(row.date), row.data.codes_issued]);
});

dateFormatter.format(dataTable, 0);

let options = {
colors: ['#007bff'],
chartArea: {
left: 60, // leave room for y-axis labels
top: 20,
bottom: 20,
width: '100%',
height: '100%',
},
hAxis: { format: 'M/d' },
legend: 'none',
};

let chart = new google.visualization.LineChart(userStatsChartDiv);
chart.draw(dataTable, options);
})
.fail(function(xhr, status, err) {
flash.error('Failed to render API key stats: ' + err);
});

dateFormatter.format(dataTable, 0);

let options = {
colors: ['#007bff'],
chartArea: {
left: 60, // leave room for y-axis labels
top: 20,
bottom: 20,
width: '100%',
height: '100%',
},
hAxis: { format: 'M/d' },
legend: 'none',
};

let chart = new google.visualization.LineChart(userStatsChartDiv);
chart.draw(dataTable, options);
})
.fail(function(xhr, status, err) {
flash.error('Failed to render API key stats: ' + err);
});
}
</script>
}
</script>
{{end}}
</body>

</html>
Expand Down
18 changes: 11 additions & 7 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
<!-- TOC depthFrom:1 -->

- [API access](#api-access)
- [API usage](#api-usage)
- [Authenticating](#authenticating)
- [Error reporting](#error-reporting)
- [API Methods](#api-methods)
- [`/api/verify`](#apiverify)
- [`/api/certificate`](#apicertificate)
- [Admin APIs](#admin-apis)
- [`/api/issue`](#apiissue)
- [Client provided UUID to prevent duplicate SMS](#client-provided-uuid-to-prevent-duplicate-sms)
- [`/api/batch-issue`](#apibatch-issue)
- [Handling batch partial success/failure](#handling-batch-partial-successfailure)
- [`/api/checkcodestatus`](#apicheckcodestatus)
- [`/api/expirecode`](#apiexpirecode)
- [`/api/stats/*` (preview)](#apistats-preview)
- [Chaffing requests](#chaffing-requests)
- [Response codes overview](#response-codes-overview)

<!-- /TOC -->

# API access

Access to the verification server API requires an API key. An API key typically
Expand All @@ -27,8 +27,12 @@ two types of API keys:
_certificates_.

- `ADMIN` - Intended for public health authority internal applications to
integrate with this server. **We strongly advise putting additional
protections in place such as an external proxy authentication.**
integrate with this server. This API key type can also retrieve statistics
about the realm. **We strongly advise putting additional protections in
place such as an external proxy authentication.**

- `STATS` - Intended for public health authorities to gather automated
statistics.


# API usage
Expand Down
33 changes: 20 additions & 13 deletions internal/routes/adminapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,13 @@ func AdminAPI(
r.Use(rateLimit)

// Other common middlewares
requireAPIKey := middleware.RequireAPIKey(cacher, db, h, []database.APIKeyType{
requireAdminAPIKey := middleware.RequireAPIKey(cacher, db, h, []database.APIKeyType{
database.APIKeyTypeAdmin,
})
requireStatsAPIKey := middleware.RequireAPIKey(cacher, db, h, []database.APIKeyType{
database.APIKeyTypeAdmin,
database.APIKeyTypeStats,
})
processFirewall := middleware.ProcessFirewall(h, "adminapi")

// Health route
Expand All @@ -97,7 +101,7 @@ func AdminAPI(
// API routes
{
sub := r.PathPrefix("/api").Subrouter()
sub.Use(requireAPIKey)
sub.Use(requireAdminAPIKey)
sub.Use(processFirewall)

issueapiController := issueapi.New(ctx, cfg, db, limiterStore, h)
Expand All @@ -107,18 +111,21 @@ func AdminAPI(
codesController := codes.NewAPI(ctx, cfg, db, h)
sub.Handle("/checkcodestatus", codesController.HandleCheckCodeStatus()).Methods("POST")
sub.Handle("/expirecode", codesController.HandleExpireAPI()).Methods("POST")
}

// Stats routes
{
sub := r.PathPrefix("/api/stats").Subrouter()
sub.Use(requireStatsAPIKey)
sub.Use(processFirewall)

{
sub := sub.PathPrefix("/stats").Subrouter()

statsController := stats.New(ctx, cacher, db, h)
sub.Handle("/realm.csv", statsController.HandleRealmStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm.json", statsController.HandleRealmStats(stats.StatsTypeJSON)).Methods("GET")
sub.Handle("/realm-user.csv", statsController.HandleRealmUserStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm-user.json", statsController.HandleRealmUserStats(stats.StatsTypeJSON)).Methods("GET")
sub.Handle("/realm-external-issuer.csv", statsController.HandleRealmExternalIssuerStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm-external-issuer.json", statsController.HandleRealmExternalIssuerStats(stats.StatsTypeJSON)).Methods("GET")
}
statsController := stats.New(ctx, cacher, db, h)
sub.Handle("/realm.csv", statsController.HandleRealmStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm.json", statsController.HandleRealmStats(stats.StatsTypeJSON)).Methods("GET")
sub.Handle("/realm-user.csv", statsController.HandleRealmUserStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm-user.json", statsController.HandleRealmUserStats(stats.StatsTypeJSON)).Methods("GET")
sub.Handle("/realm-external-issuer.csv", statsController.HandleRealmExternalIssuerStats(stats.StatsTypeCSV)).Methods("GET")
sub.Handle("/realm-external-issuer.json", statsController.HandleRealmExternalIssuerStats(stats.StatsTypeJSON)).Methods("GET")
}

// Wrap the main router in the mutating middleware method. This cannot be
Expand Down
1 change: 1 addition & 0 deletions pkg/controller/apikey/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,6 @@ func (c *Controller) renderNew(ctx context.Context, w http.ResponseWriter, authA
m["authApp"] = authApp
m["typeAdmin"] = database.APIKeyTypeAdmin
m["typeDevice"] = database.APIKeyTypeDevice
m["typeStats"] = database.APIKeyTypeStats
c.h.RenderHTML(w, "apikeys/new", m)
}
Loading

0 comments on commit 9494991

Please sign in to comment.