Skip to content

Commit 2f041ac

Browse files
committed
Add the ability for users to rotate their API keys. Closes NdoleStudio#412
1 parent 1c9adaf commit 2f041ac

7 files changed

Lines changed: 171 additions & 8 deletions

File tree

‎api/pkg/handlers/user_handler.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ func NewUserHandler(
4141
func (h *UserHandler) RegisterRoutes(router fiber.Router) {
4242
router.Get("/users/me", h.Show)
4343
router.Put("/users/me", h.Update)
44+
router.Delete("/users/:userID/api-keys", h.DeleteApiKey)
4445
router.Put("/users/:userID/notifications", h.UpdateNotifications)
4546
router.Get("/users/subscription-update-url", h.subscriptionUpdateURL)
4647
router.Delete("/users/subscription", h.cancelSubscription)
@@ -215,3 +216,37 @@ func (h *UserHandler) cancelSubscription(c *fiber.Ctx) error {
215216

216217
return h.responseNoContent(c, "Subscription cancelled successfully")
217218
}
219+
220+
// DeleteAPIKey rotates the API Key for a user
221+
// @Summary Rotate the user's API Key
222+
// @Description Rotate the user's API key in case the current API Key is compromised
223+
// @Security ApiKeyAuth
224+
// @Tags Users
225+
// @Accept json
226+
// @Produce json
227+
// @Param userID path string true "ID of the user to update" default(32343a19-da5e-4b1b-a767-3298a73703ca)
228+
// @Success 200 {object} responses.UserResponse
229+
// @Failure 400 {object} responses.BadRequest
230+
// @Failure 401 {object} responses.Unauthorized
231+
// @Failure 422 {object} responses.UnprocessableEntity
232+
// @Failure 500 {object} responses.InternalServerError
233+
// @Router /users/{userID}/api-keys [delete]
234+
func (h *UserHandler) DeleteAPIKey(c *fiber.Ctx) error {
235+
ctx, span := h.tracer.StartFromFiberCtx(c)
236+
defer span.End()
237+
238+
ctxLogger := h.tracer.CtxLogger(h.logger, span)
239+
240+
if c.Params("userID") != string(h.userIDFomContext(c)) {
241+
return h.responseUnauthorized(c)
242+
}
243+
244+
user, err := h.service.RotateAPIKey(ctx, h.userIDFomContext(c))
245+
if err != nil {
246+
msg := fmt.Sprintf("cannot rotate the api key for [%T] with ID [%s]", user, h.userIDFomContext(c))
247+
ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)))
248+
return h.responseInternalServerError(c)
249+
}
250+
251+
return h.responseOK(c, "API Key rotated successfully", user)
252+
}

‎api/pkg/repositories/gorm_user_repository.go‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"fmt"
99
"time"
1010

11+
"gorm.io/gorm/clause"
12+
1113
"github.com/cockroachdb/cockroach-go/v2/crdb/crdbgorm"
1214
"github.com/dgraph-io/ristretto"
1315

@@ -40,6 +42,32 @@ func NewGormUserRepository(
4042
}
4143
}
4244

45+
func (repository *gormUserRepository) RotateAPIKey(ctx context.Context, userID entities.UserID) (*entities.User, error) {
46+
ctx, span := repository.tracer.Start(ctx)
47+
defer span.End()
48+
49+
apiKey, err := repository.generateAPIKey(64)
50+
if err != nil {
51+
return nil, stacktrace.Propagate(err, fmt.Sprintf("cannot generate apiKey for user [%s]", userID))
52+
}
53+
54+
user := new(entities.User)
55+
err = crdbgorm.ExecuteTx(ctx, repository.db, nil,
56+
func(tx *gorm.DB) error {
57+
return tx.WithContext(ctx).Model(user).
58+
Clauses(clause.Returning{}).
59+
Where("id = ?", userID).
60+
Update("api_key", apiKey).Error
61+
},
62+
)
63+
if errors.Is(err, gorm.ErrRecordNotFound) {
64+
msg := fmt.Sprintf("user with ID [%s] does not exist", userID)
65+
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg))
66+
}
67+
68+
return user, nil
69+
}
70+
4371
func (repository *gormUserRepository) LoadBySubscriptionID(ctx context.Context, subscriptionID string) (*entities.User, error) {
4472
ctx, span := repository.tracer.Start(ctx)
4573
defer span.End()
@@ -150,7 +178,7 @@ func (repository *gormUserRepository) LoadOrStore(ctx context.Context, authUser
150178

151179
apiKey, err := repository.generateAPIKey(64)
152180
if err != nil {
153-
return nil, false, stacktrace.Propagate(err, "cannot generate apiKey")
181+
return nil, false, stacktrace.Propagate(err, fmt.Sprintf("cannot generate apiKey for user [%s]", authUser.ID))
154182
}
155183

156184
user = &entities.User{

‎api/pkg/repositories/user_repository.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ type UserRepository interface {
2020
// Load an entities.User by entities.UserID
2121
Load(ctx context.Context, userID entities.UserID) (*entities.User, error)
2222

23+
// RotateAPIKey updates the API Key of a user
24+
RotateAPIKey(ctx context.Context, userID entities.UserID) (*entities.User, error)
25+
2326
// LoadOrStore an entities.User by entities.AuthUser
2427
LoadOrStore(ctx context.Context, user entities.AuthUser) (*entities.User, bool, error)
2528

‎api/pkg/services/user_service.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,21 @@ func (service *UserService) UpdateNotificationSettings(ctx context.Context, user
149149
return user, nil
150150
}
151151

152+
// RotateAPIKey for an entities.User
153+
func (service *UserService) RotateAPIKey(ctx context.Context, userID entities.UserID) (*entities.User, error) {
154+
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
155+
defer span.End()
156+
157+
user, err := service.repository.RotateAPIKey(ctx, userID)
158+
if err != nil {
159+
msg := fmt.Sprintf("could not rotate API key for [%T] with ID [%s]", user, userID)
160+
return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
161+
}
162+
163+
ctxLogger.Info(fmt.Sprintf("rotated the api key for [%T] with ID [%s] in the [%T]", user, user.ID, service.repository))
164+
return user, nil
165+
}
166+
152167
// UserSendPhoneDeadEmailParams are parameters for notifying a user when a phone is dead
153168
type UserSendPhoneDeadEmailParams struct {
154169
UserID entities.UserID

‎web/components/CopyButton.vue‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
:large="large"
88
@click="copy"
99
>
10-
<v-icon>{{ mdiContentCopy }}</v-icon>
10+
<v-icon left>{{ mdiContentCopy }}</v-icon>
1111
{{ copyText }}
1212
</v-btn>
1313
</template>

‎web/pages/settings/index.vue‎

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,8 @@
7575
class="mb-n2"
7676
@click:append="apiKeyShow = !apiKeyShow"
7777
></v-text-field>
78-
<div class="d-flex">
78+
<div class="d-flex flex-wrap">
7979
<copy-button
80-
:block="$vuetify.breakpoint.mdAndDown"
81-
:large="$vuetify.breakpoint.mdAndDown"
8280
:value="apiKey"
8381
color="primary"
8482
copy-text="Copy API Key"
@@ -90,6 +88,49 @@
9088
:href="$store.getters.getAppData.documentationUrl"
9189
>Documentation</v-btn
9290
>
91+
<v-spacer></v-spacer>
92+
<v-dialog
93+
v-model="showRotateApiKey"
94+
overlay-opacity="0.9"
95+
max-width="550"
96+
>
97+
<template #activator="{ on, attrs }">
98+
<v-btn
99+
:small="$vuetify.breakpoint.mdAndDown"
100+
:text="$vuetify.breakpoint.lgAndUp"
101+
color="warning"
102+
v-bind="attrs"
103+
v-on="on"
104+
>
105+
<v-icon left>{{ mdiRefresh }}</v-icon>
106+
Rotate API Key
107+
</v-btn>
108+
</template>
109+
<v-card>
110+
<v-card-title class="text-h5 text-break">
111+
Are you sure you want to rotate your API Key?
112+
</v-card-title>
113+
<v-card-text>
114+
You will have to logout and login again on the
115+
<b>httpSMS</b> Android app with your new API key after you
116+
rotate it.
117+
</v-card-text>
118+
<v-card-actions class="pb-4">
119+
<v-btn
120+
color="primary"
121+
:loading="rotatingApiKey"
122+
@click="rotateApiKey"
123+
>
124+
<v-icon left>{{ mdiRefresh }}</v-icon>
125+
Yes Rotate Key
126+
</v-btn>
127+
<v-spacer></v-spacer>
128+
<v-btn text @click="showRotateApiKey = false">
129+
Close
130+
</v-btn>
131+
</v-card-actions>
132+
</v-card>
133+
</v-dialog>
93134
</div>
94135
<h5 id="webhook-settings" class="text-h4 mb-3 mt-12">Webhooks</h5>
95136
<p class="text--secondary">
@@ -345,7 +386,7 @@
345386
</v-row>
346387
</v-container>
347388
</div>
348-
<v-dialog v-model="showPhoneEdit" max-width="700px">
389+
<v-dialog v-model="showPhoneEdit" overlay-opacity="0.9" max-width="700px">
349390
<v-card>
350391
<v-card-title>Edit Phone</v-card-title>
351392
<v-card-text v-if="activePhone" class="mt-6">
@@ -441,7 +482,7 @@
441482
</v-card-actions>
442483
</v-card>
443484
</v-dialog>
444-
<v-dialog v-model="showWebhookEdit" max-width="600px">
485+
<v-dialog v-model="showWebhookEdit" overlay-opacity="0.9" max-width="600px">
445486
<v-card>
446487
<v-card-title>
447488
<span v-if="!activeWebhook.id">Add a new&nbsp;</span>
@@ -556,7 +597,7 @@
556597
</v-card-actions>
557598
</v-card>
558599
</v-dialog>
559-
<v-dialog v-model="showDiscordEdit" max-width="700px">
600+
<v-dialog v-model="showDiscordEdit" overlay-opacity="0.9" max-width="700px">
560601
<v-card>
561602
<v-card-title>
562603
<span v-if="!activeDiscord.id">Add a new&nbsp;</span>
@@ -682,6 +723,7 @@ import {
682723
mdiContentSave,
683724
mdiConnection,
684725
mdiEye,
726+
mdiRefresh,
685727
mdiLinkVariant,
686728
mdiEyeOff,
687729
mdiSquareEditOutline,
@@ -697,6 +739,7 @@ export default Vue.extend({
697739
return {
698740
mdiEye,
699741
mdiEyeOff,
742+
mdiRefresh,
700743
mdiArrowLeft,
701744
mdiAccountCircle,
702745
mdiShieldCheck,
@@ -709,6 +752,8 @@ export default Vue.extend({
709752
apiKeyShow: false,
710753
showPhoneEdit: false,
711754
showDiscordEdit: false,
755+
showRotateApiKey: false,
756+
rotatingApiKey: false,
712757
activeWebhook: {
713758
id: null,
714759
url: '',
@@ -1022,6 +1067,16 @@ export default Vue.extend({
10221067
})
10231068
},
10241069
1070+
rotateApiKey() {
1071+
this.rotatingApiKey = true
1072+
this.$store
1073+
.dispatch('rotateApiKey', this.$store.getters.getUser.id)
1074+
.finally(() => {
1075+
this.rotatingApiKey = false
1076+
this.showRotateApiKey = false
1077+
})
1078+
},
1079+
10251080
deleteWebhook(webhookId) {
10261081
this.updatingWebhook = true
10271082
this.$store

‎web/store/index.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,33 @@ export const actions = {
886886
})
887887
},
888888

889+
rotateApiKey(context: ActionContext<State, State>, payload: string) {
890+
return new Promise<EntitiesUser>((resolve, reject) => {
891+
axios
892+
.delete<ResponsesUserResponse>(`/v1/users/${payload}/api-keys`)
893+
.then((response: AxiosResponse<ResponsesUserResponse>) => {
894+
context.commit('setUser', response.data.data)
895+
setApiKey(response.data.data.api_key)
896+
context.dispatch('addNotification', {
897+
message: 'API Key rotated successfully',
898+
type: 'success',
899+
})
900+
resolve(response.data.data)
901+
})
902+
.catch(async (error: AxiosError) => {
903+
await Promise.all([
904+
context.dispatch('addNotification', {
905+
message:
906+
(error.response?.data as any)?.message ??
907+
'Error while rotating your API key',
908+
type: 'error',
909+
}),
910+
])
911+
reject(getErrorMessages(error))
912+
})
913+
})
914+
},
915+
889916
updateWebhook(
890917
context: ActionContext<State, State>,
891918
payload: RequestsWebhookUpdate & { id: string },

0 commit comments

Comments
 (0)