Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement DELETE /user/pubkey/:pubKey #159

Merged
merged 6 commits into from
Mar 25, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func TestUserTierCache(t *testing.T) {
if ok || tier != database.TierAnonymous {
t.Fatalf("Expected to get tier %d and %t, got %d and %t.", database.TierAnonymous, false, tier, ok)
}
// Set the use in the cache.
// Set the user in the cache.
cache.Set(u.Sub, u)
// Check again.
tier, qe, ok := cache.Get(u.Sub)
Expand Down
38 changes: 35 additions & 3 deletions api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,34 @@ func (api *API) userPUT(u *database.User, w http.ResponseWriter, req *http.Reque
api.loginUser(w, u, true)
}

// userPubKeyDELETE removes a given pubkey from the list of pubkeys associated
// with this user. It does not require a challenge-response because the used
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
// does not need to prove the key is theirs.
func (api *API) userPubKeyDELETE(u *database.User, w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
ctx := req.Context()
var pk database.PubKey
err := pk.LoadString(ps.ByName("pubKey"))
if err != nil {
api.WriteError(w, err, http.StatusBadRequest)
return
}
if !u.HasKey(pk) {
// This pubkey does not belong to this user.
api.WriteError(w, errors.New("the given pubkey is not associated with this user"), http.StatusBadRequest)
return
}
err = api.staticDB.UserPubKeyRemove(ctx, *u, pk)
if errors.Contains(err, mongo.ErrNoDocuments) {
api.WriteError(w, err, http.StatusNotFound)
return
}
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
return
}
api.WriteSuccess(w)
}

// userPubKeyRegisterGET generates an update challenge for the caller.
func (api *API) userPubKeyRegisterGET(u *database.User, w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
ctx := req.Context()
Expand Down Expand Up @@ -814,8 +842,12 @@ func (api *API) userPubKeyRegisterPOST(u *database.User, w http.ResponseWriter,
api.WriteError(w, errors.New("user's sub doesn't match update sub"), http.StatusBadRequest)
return
}
u.PubKeys = append(u.PubKeys, pk)
err = api.staticDB.UserSave(ctx, u)
err = api.staticDB.UserPubKeyAdd(ctx, *u, pk)
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
return
}
updatedUser, err := api.staticDB.UserByID(ctx, u.ID)
if err != nil {
api.WriteError(w, err, http.StatusInternalServerError)
return
Expand All @@ -825,7 +857,7 @@ func (api *API) userPubKeyRegisterPOST(u *database.User, w http.ResponseWriter,
api.WriteError(w, err, http.StatusInternalServerError)
return
}
api.loginUser(w, u, true)
api.loginUser(w, updatedUser, true)
}

// userUploadsGET returns all uploads made by the current user.
Expand Down
1 change: 1 addition & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func (api *API) buildHTTPRoutes() {
api.staticRouter.GET("/user/limits", api.noAuth(api.userLimitsGET))
api.staticRouter.GET("/user/limits/:skylink", api.noAuth(api.userLimitsSkylinkGET))
api.staticRouter.GET("/user/stats", api.withAuth(api.userStatsGET))
api.staticRouter.DELETE("/user/pubkey/:pubKey", api.WithDBSession(api.withAuth(api.userPubKeyDELETE)))
api.staticRouter.GET("/user/pubkey/register", api.WithDBSession(api.withAuth(api.userPubKeyRegisterGET)))
api.staticRouter.POST("/user/pubkey/register", api.WithDBSession(api.withAuth(api.userPubKeyRegisterPOST)))
api.staticRouter.GET("/user/uploads", api.withAuth(api.userUploadsGET))
Expand Down
49 changes: 45 additions & 4 deletions database/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/subtle"
"fmt"
"net/mail"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -128,10 +129,7 @@ type (
SubscriptionCancelAtPeriodEnd bool `bson:"subscription_cancel_at_period_end" json:"subscriptionCancelAtPeriodEnd"`
StripeID string `bson:"stripe_id" json:"stripeCustomerId"`
QuotaExceeded bool `bson:"quota_exceeded" json:"quotaExceeded"`
// The currently active (or default) key is going to be the first one in
// the list. If we want to activate a new pubkey, we'll just move it to
// the first position in the list.
PubKeys []PubKey `bson:"pub_keys" json:"-"`
PubKeys []PubKey `bson:"pub_keys" json:"-"`
}
// UserStats contains statistical information about the user.
UserStats struct {
Expand Down Expand Up @@ -477,6 +475,49 @@ func (db *DB) UserSave(ctx context.Context, u *User) error {
return nil
}

// UserPubKeyAdd adds a new PubKey to the given user's set.
func (db *DB) UserPubKeyAdd(ctx context.Context, u User, pk PubKey) (err error) {
filter := bson.M{"_id": u.ID}
// This update is so complicated because we can't use mutation operations
// like $push, $addToSet and so on if the target field is null. That's why
// here we check if the field is an array and then merge the key in. If the
// field is not an array (i.e. it's null) we set it to an empty array before
// performing the merge.
update := bson.A{
bson.M{
"$set": bson.M{
"pub_keys": bson.M{
"$cond": bson.A{
bson.M{"$eq": bson.A{bson.M{"$type": "$pub_keys"}, "array"}},
bson.M{"$setUnion": bson.A{"$pub_keys", bson.A{pk}}},
bson.A{pk},
}},
},
},
}
opts := options.UpdateOptions{
Upsert: &False,
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
}
_, err = db.staticUsers.UpdateOne(ctx, filter, update, &opts)
return err
}

// UserPubKeyRemove removes a PubKey from the given user's set.
func (db *DB) UserPubKeyRemove(ctx context.Context, u User, pk PubKey) error {
filter := bson.M{"_id": u.ID}
update := bson.M{
"$pull": bson.M{"pub_keys": pk},
}
opts := options.UpdateOptions{
Upsert: &False,
}
_, err := db.staticUsers.UpdateOne(ctx, filter, update, &opts)
if err != nil && strings.Contains(err.Error(), "Cannot apply $pull to a non-array value") {
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
return mongo.ErrNoDocuments
}
return err
}

// UserSetStripeID changes the user's stripe id in the DB.
func (db *DB) UserSetStripeID(ctx context.Context, u *User, stripeID string) error {
filter := bson.M{"_id": u.ID}
Expand Down
24 changes: 20 additions & 4 deletions test/api/apikeys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ func testPublicAPIKeysFlow(t *testing.T, at *test.AccountsTester) {
if len(aks) != 0 {
t.Fatalf("Expected no API keys, got %d.", len(aks))
}
// Delete the same key again. Expect a 404.
status, err = at.UserAPIKeysDELETE(akr.ID)
if status != http.StatusNotFound {
t.Fatal("Expected status 404, got", status)
}
}

// testPublicAPIKeysUsage makes sure that we can use public API keys to make
Expand Down Expand Up @@ -255,12 +260,12 @@ func testPublicAPIKeysUsage(t *testing.T, at *test.AccountsTester) {
Public: true,
Skylinks: []string{sl.Skylink},
}
akWithKey, _, err := at.UserAPIKeysPOST(apiKeyPOST)
pakWithKey, _, err := at.UserAPIKeysPOST(apiKeyPOST)
if err != nil {
t.Fatal(err)
}
// Stop using the cookie, use the public API key instead.
at.SetAPIKey(akWithKey.Key.String())
at.SetAPIKey(pakWithKey.Key.String())
// Try to fetch the user's stats with the new public API key.
// Expect this to fail.
_, _, err = at.Get("/user/stats", nil)
Expand All @@ -269,7 +274,7 @@ func testPublicAPIKeysUsage(t *testing.T, at *test.AccountsTester) {
}
// Get the user's limits for downloading a skylink covered by the public
// API key. Expect to get TierFree values.
ul, _, err := at.UserLimitsSkylink(sl.Skylink, "byte", nil)
ul, _, err := at.UserLimitsSkylink(sl.Skylink, "byte", "", nil)
if err != nil {
t.Fatal(err)
}
Expand All @@ -278,11 +283,22 @@ func testPublicAPIKeysUsage(t *testing.T, at *test.AccountsTester) {
}
// Get the user's limits for downloading a skylink that is not covered by
// the public API key. Expect to get TierAnonymous values.
ul, _, err = at.UserLimitsSkylink(sl2.Skylink, "byte", nil)
ul, _, err = at.UserLimitsSkylink(sl2.Skylink, "byte", "", nil)
if err != nil {
t.Fatal(err)
}
if ul.DownloadBandwidth != database.UserLimits[database.TierAnonymous].DownloadBandwidth {
t.Fatalf("Expected to get download bandwidth of %d, got %d", database.UserLimits[database.TierAnonymous].DownloadBandwidth, ul.DownloadBandwidth)
}
// Stop using the header, pass the skylink as a query parameter.
at.ClearCredentials()
// Get the user's limits for downloading a skylink covered by the public
// API key. Expect to get TierFree values.
ul, _, err = at.UserLimitsSkylink(sl.Skylink, "byte", pakWithKey.Key.String(), nil)
if err != nil {
t.Fatal(err)
}
if ul.DownloadBandwidth != database.UserLimits[database.TierFree].DownloadBandwidth {
t.Fatalf("Expected to get download bandwidth of %d, got %d", database.UserLimits[database.TierFree].DownloadBandwidth, ul.DownloadBandwidth)
}
}
90 changes: 90 additions & 0 deletions test/api/challenge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,93 @@ func testUserAddPubKey(t *testing.T, at *test.AccountsTester) {
t.Fatalf("Expected pubKey '%s', got '%s',", hex.EncodeToString(pk[:]), hex.EncodeToString(u3.PubKeys[0]))
}
}

// testUserDeletePubKey ensures that users can delete pubkeys from their
// accounts.
func testUserDeletePubKey(t *testing.T, at *test.AccountsTester) {
name := test.DBNameForTest(t.Name())
u, c, err := test.CreateUserAndLogin(at, name)
if err != nil {
t.Fatal("Failed to create a user and log in:", err)
}
defer func() {
if err = u.Delete(at.Ctx); err != nil {
t.Error(errors.AddContext(err, "failed to delete user in defer"))
}
}()
at.SetCookie(c)
defer at.ClearCredentials()

sk, pk := crypto.GenerateKeyPair()
var ch database.Challenge

// Request a new challenge.
queryParams := url.Values{}
queryParams.Set("pubKey", hex.EncodeToString(pk[:]))
r, b, err := at.Get("/user/pubkey/register", queryParams)
err = json.Unmarshal(b, &ch)
if err != nil || r.StatusCode != http.StatusOK {
t.Fatal("Failed to get a challenge:", err, r.Status, string(b))
}
chBytes, err := hex.DecodeString(ch.Challenge)
if err != nil {
t.Fatal("Invalid challenge:", err)
}
// Solve the challenge.
response := append(chBytes, append([]byte(database.ChallengeTypeUpdate), []byte(database.PortalName)...)...)
bodyParams := url.Values{}
bodyParams.Set("response", hex.EncodeToString(response))
bodyParams.Set("signature", hex.EncodeToString(ed25519.Sign(sk[:], response)))
r, b, err = at.Post("/user/pubkey/register", nil, bodyParams)
if err != nil {
t.Fatalf("Failed to confirm the update. Status %d, body '%s', error '%s'", r.StatusCode, string(b), err)
}
ro-tex marked this conversation as resolved.
Show resolved Hide resolved
// Make sure the user's pubKey is properly set.
u1, err := at.DB.UserBySub(at.Ctx, u.Sub)
if err != nil {
t.Fatal(err)
}
if len(u1.PubKeys) != 1 {
t.Fatal("Expected one pubkey assigned, got none.")
}
if subtle.ConstantTimeCompare(u1.PubKeys[0], pk[:]) != 1 {
t.Fatalf("Expected pubKey '%s', got '%s',", hex.EncodeToString(pk[:]), hex.EncodeToString(u1.PubKeys[0]))
}

// Call DELETE without a cookie.
at.ClearCredentials()
r, b, err = at.Delete("/user/pubkey/"+hex.EncodeToString(pk[:]), nil)
if err == nil || r.StatusCode != http.StatusUnauthorized {
t.Fatalf("Expected to fail with 401. Status %d, body '%s', error '%s'", r.StatusCode, string(b), err)
}
at.SetCookie(c)
// Call DELETE with an invalid key.
r, b, err = at.Delete("/user/pubkey/INVALID_KEY", nil)
if err == nil || r.StatusCode != http.StatusBadRequest {
t.Fatalf("Expected to fail with 400. Status %d, body '%s', error '%s'", r.StatusCode, string(b), err)
}
_, pk1 := crypto.GenerateKeyPair()
// Call DELETE with a key that doesn't belong to this user.
r, b, err = at.Delete("/user/pubkey/"+hex.EncodeToString(pk1[:]), nil)
if err == nil || r.StatusCode != http.StatusBadRequest {
t.Fatalf("Expected to fail with 400. Status %d, body '%s', error '%s'", r.StatusCode, string(b), err)
}
// Call DELETE with correct parameters.
r, b, err = at.Delete("/user/pubkey/"+hex.EncodeToString(pk[:]), nil)
if err != nil || r.StatusCode != http.StatusNoContent {
t.Fatalf("Expected to succeed. Status %d, body '%s', error '%s'", r.StatusCode, string(b), err)
}
// Verify that the key was deleted.
u2, err := at.DB.UserBySub(at.Ctx, u.Sub)
if err != nil {
t.Fatal(err)
}
if len(u2.PubKeys) > 0 {
t.Fatal("Expected no public keys, got", len(u2.PubKeys))
}
// Call DELETE with the already deleted key.
r, b, err = at.Delete("/user/pubkey/"+hex.EncodeToString(pk[:]), nil)
if err == nil || r.StatusCode != http.StatusBadRequest {
t.Fatalf("Expected to fail with 400. Status %d, body '%s', error '%s'", r.StatusCode, string(b), err)
}
}
1 change: 1 addition & 0 deletions test/api/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func TestHandlers(t *testing.T) {
{name: "LoginLogout", test: testHandlerLoginPOST},
{name: "UserEdit", test: testUserPUT},
{name: "UserAddPubKey", test: testUserAddPubKey},
{name: "DeletePubKey", test: testUserDeletePubKey},
{name: "UserDelete", test: testUserDELETE},
{name: "UserLimits", test: testUserLimits},
{name: "UserDeleteUploads", test: testUserUploadsDELETE},
Expand Down
Loading