diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c1cb8d7cc..92253214a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,3 +4,5 @@ * [ ] I have added any new tests that need to pass to `sytest-whitelist` as specified in [docs/sytest.md](https://github.com/matrix-org/dendrite/blob/master/docs/sytest.md) * [ ] Pull request includes a [sign off](https://github.com/matrix-org/dendrite/blob/master/docs/CONTRIBUTING.md#sign-off) + +Signed-off-by: `Your Name ` diff --git a/clientapi/routing/deactivate.go b/clientapi/routing/deactivate.go new file mode 100644 index 000000000..effe3769d --- /dev/null +++ b/clientapi/routing/deactivate.go @@ -0,0 +1,55 @@ +package routing + +import ( + "io/ioutil" + "net/http" + + "github.com/matrix-org/dendrite/clientapi/auth" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/userapi/api" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +// Deactivate handles POST requests to /account/deactivate +func Deactivate( + req *http.Request, + userInteractiveAuth *auth.UserInteractive, + userAPI api.UserInternalAPI, + deviceAPI *api.Device, +) util.JSONResponse { + ctx := req.Context() + defer req.Body.Close() // nolint:errcheck + bodyBytes, err := ioutil.ReadAll(req.Body) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("The request body could not be read: " + err.Error()), + } + } + + login, errRes := userInteractiveAuth.Verify(ctx, bodyBytes, deviceAPI) + if errRes != nil { + return *errRes + } + + localpart, _, err := gomatrixserverlib.SplitID('@', login.User) + if err != nil { + util.GetLogger(req.Context()).WithError(err).Error("gomatrixserverlib.SplitID failed") + return jsonerror.InternalServerError() + } + + var res api.PerformAccountDeactivationResponse + err = userAPI.PerformAccountDeactivation(ctx, &api.PerformAccountDeactivationRequest{ + Localpart: localpart, + }, &res) + if err != nil { + util.GetLogger(ctx).WithError(err).Error("userAPI.PerformAccountDeactivation failed") + return jsonerror.InternalServerError() + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} diff --git a/clientapi/routing/routing.go b/clientapi/routing/routing.go index ab56c5f4d..8606f69c3 100644 --- a/clientapi/routing/routing.go +++ b/clientapi/routing/routing.go @@ -435,6 +435,15 @@ func Setup( }), ).Methods(http.MethodPost, http.MethodOptions) + r0mux.Handle("/account/deactivate", + httputil.MakeAuthAPI("deactivate", userAPI, func(req *http.Request, device *userapi.Device) util.JSONResponse { + if r := rateLimits.rateLimit(req); r != nil { + return *r + } + return Deactivate(req, userInteractiveAuth, userAPI, device) + }), + ).Methods(http.MethodPost, http.MethodOptions) + // Stub endpoints required by Riot r0mux.Handle("/login", diff --git a/syncapi/routing/messages.go b/syncapi/routing/messages.go index 6447e5d59..9c6c6a80d 100644 --- a/syncapi/routing/messages.go +++ b/syncapi/routing/messages.go @@ -26,6 +26,7 @@ import ( "github.com/matrix-org/dendrite/roomserver/api" "github.com/matrix-org/dendrite/syncapi/storage" "github.com/matrix-org/dendrite/syncapi/types" + userapi "github.com/matrix-org/dendrite/userapi/api" "github.com/matrix-org/gomatrixserverlib" "github.com/matrix-org/util" "github.com/sirupsen/logrus" @@ -41,6 +42,7 @@ type messagesReq struct { from *types.TopologyToken to *types.TopologyToken fromStream *types.StreamingToken + device *userapi.Device wasToProvided bool limit int backwardOrdering bool @@ -58,7 +60,7 @@ const defaultMessagesLimit = 10 // client-server API. // See: https://matrix.org/docs/spec/client_server/latest.html#get-matrix-client-r0-rooms-roomid-messages func OnIncomingMessagesRequest( - req *http.Request, db storage.Database, roomID string, + req *http.Request, db storage.Database, roomID string, device *userapi.Device, federation *gomatrixserverlib.FederationClient, rsAPI api.RoomserverInternalAPI, cfg *config.SyncAPI, @@ -151,6 +153,7 @@ func OnIncomingMessagesRequest( wasToProvided: wasToProvided, limit: limit, backwardOrdering: backwardOrdering, + device: device, } clientEvents, start, end, err := mReq.retrieveEvents() @@ -238,6 +241,10 @@ func (r *messagesReq) retrieveEvents() ( } events = reversed(events) } + events = r.filterHistoryVisible(events) + if len(events) == 0 { + return []gomatrixserverlib.ClientEvent{}, *r.from, *r.to, nil + } // Convert all of the events into client events. clientEvents = gomatrixserverlib.HeaderedToClientEvents(events, gomatrixserverlib.FormatAll) @@ -252,6 +259,90 @@ func (r *messagesReq) retrieveEvents() ( return clientEvents, start, end, err } +// nolint:gocyclo +func (r *messagesReq) filterHistoryVisible(events []gomatrixserverlib.HeaderedEvent) []gomatrixserverlib.HeaderedEvent { + // TODO FIXME: We don't fully implement history visibility yet. To avoid leaking events which the + // user shouldn't see, we check the recent events and remove any prior to the join event of the user + // which is equiv to history_visibility: joined + joinEventIndex := -1 + for i, ev := range events { + if ev.Type() == gomatrixserverlib.MRoomMember && ev.StateKeyEquals(r.device.UserID) { + membership, _ := ev.Membership() + if membership == "join" { + joinEventIndex = i + break + } + } + } + + var result []gomatrixserverlib.HeaderedEvent + var eventsToCheck []gomatrixserverlib.HeaderedEvent + if joinEventIndex != -1 { + if r.backwardOrdering { + result = events[:joinEventIndex+1] + eventsToCheck = append(eventsToCheck, result[0]) + } else { + result = events[joinEventIndex:] + eventsToCheck = append(eventsToCheck, result[len(result)-1]) + } + } else { + eventsToCheck = []gomatrixserverlib.HeaderedEvent{events[0], events[len(events)-1]} + result = events + } + // make sure the user was in the room for both the earliest and latest events, we need this because + // some backpagination results will not have the join event (e.g if they hit /messages at the join event itself) + wasJoined := true + for _, ev := range eventsToCheck { + var queryRes api.QueryStateAfterEventsResponse + err := r.rsAPI.QueryStateAfterEvents(r.ctx, &api.QueryStateAfterEventsRequest{ + RoomID: ev.RoomID(), + PrevEventIDs: ev.PrevEventIDs(), + StateToFetch: []gomatrixserverlib.StateKeyTuple{ + {EventType: gomatrixserverlib.MRoomMember, StateKey: r.device.UserID}, + {EventType: gomatrixserverlib.MRoomHistoryVisibility, StateKey: ""}, + }, + }, &queryRes) + if err != nil { + wasJoined = false + break + } + var hisVisEvent, membershipEvent *gomatrixserverlib.HeaderedEvent + for i := range queryRes.StateEvents { + switch queryRes.StateEvents[i].Type() { + case gomatrixserverlib.MRoomMember: + membershipEvent = &queryRes.StateEvents[i] + case gomatrixserverlib.MRoomHistoryVisibility: + hisVisEvent = &queryRes.StateEvents[i] + } + } + if hisVisEvent == nil { + return events // apply no filtering as it defaults to Shared. + } + hisVis, _ := hisVisEvent.HistoryVisibility() + if hisVis == "shared" { + return events // apply no filtering + } + if membershipEvent == nil { + wasJoined = false + break + } + membership, err := membershipEvent.Membership() + if err != nil { + wasJoined = false + break + } + if membership != "join" { + wasJoined = false + break + } + } + if !wasJoined { + util.GetLogger(r.ctx).WithField("num_events", len(events)).Warnf("%s was not joined to room during these events, omitting them", r.device.UserID) + return []gomatrixserverlib.HeaderedEvent{} + } + return result +} + func (r *messagesReq) getStartEnd(events []gomatrixserverlib.HeaderedEvent) (start, end types.TopologyToken, err error) { start, err = r.db.EventPositionInTopology( r.ctx, events[0].EventID(), diff --git a/syncapi/routing/routing.go b/syncapi/routing/routing.go index f42679c67..141eec799 100644 --- a/syncapi/routing/routing.go +++ b/syncapi/routing/routing.go @@ -51,7 +51,7 @@ func Setup( if err != nil { return util.ErrorResponse(err) } - return OnIncomingMessagesRequest(req, syncDB, vars["roomID"], federation, rsAPI, cfg) + return OnIncomingMessagesRequest(req, syncDB, vars["roomID"], device, federation, rsAPI, cfg) })).Methods(http.MethodGet, http.MethodOptions) r0mux.Handle("/user/{userId}/filter", diff --git a/syncapi/storage/shared/syncserver.go b/syncapi/storage/shared/syncserver.go index edb51b347..b9f21913e 100644 --- a/syncapi/storage/shared/syncserver.go +++ b/syncapi/storage/shared/syncserver.go @@ -769,6 +769,33 @@ func (d *Database) getJoinResponseForCompleteSync( return } + // TODO FIXME: We don't fully implement history visibility yet. To avoid leaking events which the + // user shouldn't see, we check the recent events and remove any prior to the join event of the user + // which is equiv to history_visibility: joined + joinEventIndex := -1 + for i := len(recentStreamEvents) - 1; i >= 0; i-- { + ev := recentStreamEvents[i] + if ev.Type() == gomatrixserverlib.MRoomMember && ev.StateKeyEquals(device.UserID) { + membership, _ := ev.Membership() + if membership == "join" { + joinEventIndex = i + if i > 0 { + // the create event happens before the first join, so we should cut it at that point instead + if recentStreamEvents[i-1].Type() == gomatrixserverlib.MRoomCreate && recentStreamEvents[i-1].StateKeyEquals("") { + joinEventIndex = i - 1 + break + } + } + break + } + } + } + if joinEventIndex != -1 { + // cut all events earlier than the join (but not the join itself) + recentStreamEvents = recentStreamEvents[joinEventIndex:] + limited = false // so clients know not to try to backpaginate + } + // Retrieve the backward topology position, i.e. the position of the // oldest event in the room's topology. var prevBatchStr string diff --git a/syncapi/storage/storage_test.go b/syncapi/storage/storage_test.go index 8f16642f0..2869ac5d2 100644 --- a/syncapi/storage/storage_test.go +++ b/syncapi/storage/storage_test.go @@ -689,6 +689,7 @@ func assertInvitedToRooms(t *testing.T, res *types.Response, roomIDs []string) { } func assertEventsEqual(t *testing.T, msg string, checkRoomID bool, gots []gomatrixserverlib.ClientEvent, wants []gomatrixserverlib.HeaderedEvent) { + t.Helper() if len(gots) != len(wants) { t.Fatalf("%s response returned %d events, want %d", msg, len(gots), len(wants)) } diff --git a/sytest-whitelist b/sytest-whitelist index df71e275f..a811259fb 100644 --- a/sytest-whitelist +++ b/sytest-whitelist @@ -90,8 +90,6 @@ Real non-joined users can get state for world_readable rooms Real non-joined users can get individual state for world_readable rooms #Real non-joined users can get individual state for world_readable rooms after leaving Real non-joined users cannot send messages to guest_access rooms if not joined -Real users can sync from world_readable guest_access rooms if joined -Real users can sync from default guest_access rooms if joined Can't forget room you're still in Can get rooms/{roomId}/members Can create filter @@ -221,6 +219,9 @@ Regular users cannot create room aliases within the AS namespace Deleting a non-existent alias should return a 404 Users can't delete other's aliases Outbound federation can query room alias directory +Can deactivate account +Can't deactivate account with wrong password +After deactivating account, can't log in with password After deactivating account, can't log in with an email Remote room alias queries can handle Unicode Newly joined room is included in an incremental sync after invite @@ -236,13 +237,11 @@ Outbound federation can query v2 /send_join Inbound federation can receive v2 /send_join Message history can be paginated Getting messages going forward is limited for a departed room (SPEC-216) -m.room.history_visibility == "world_readable" allows/forbids appropriately for Real users Backfill works correctly with history visibility set to joined Guest user cannot call /events globally Guest users can join guest_access rooms Guest user can set display names Guest user cannot upgrade other users -m.room.history_visibility == "world_readable" allows/forbids appropriately for Guest users Guest non-joined user cannot call /events on shared room Guest non-joined user cannot call /events on invited room Guest non-joined user cannot call /events on joined room @@ -252,8 +251,6 @@ Guest non-joined users can get individual state for world_readable rooms Guest non-joined users cannot room initalSync for non-world_readable rooms Guest non-joined users can get individual state for world_readable rooms after leaving Guest non-joined users cannot send messages to guest_access rooms if not joined -Guest users can sync from world_readable guest_access rooms if joined -Guest users can sync from default guest_access rooms if joined Real non-joined users cannot room initalSync for non-world_readable rooms Push rules come down in an initial /sync Regular users can add and delete aliases in the default room configuration @@ -478,4 +475,6 @@ Federation key API can act as a notary server via a GET request Inbound /make_join rejects attempts to join rooms where all users have left Inbound federation rejects invites which include invalid JSON for room version 6 Inbound federation rejects invite rejections which include invalid JSON for room version 6 -GET /capabilities is present and well formed for registered user \ No newline at end of file +GET /capabilities is present and well formed for registered user +m.room.history_visibility == "joined" allows/forbids appropriately for Guest users +m.room.history_visibility == "joined" allows/forbids appropriately for Real users \ No newline at end of file diff --git a/userapi/api/api.go b/userapi/api/api.go index 50b4a7bb0..a7539d098 100644 --- a/userapi/api/api.go +++ b/userapi/api/api.go @@ -30,6 +30,7 @@ type UserInternalAPI interface { PerformDeviceCreation(ctx context.Context, req *PerformDeviceCreationRequest, res *PerformDeviceCreationResponse) error PerformDeviceDeletion(ctx context.Context, req *PerformDeviceDeletionRequest, res *PerformDeviceDeletionResponse) error PerformDeviceUpdate(ctx context.Context, req *PerformDeviceUpdateRequest, res *PerformDeviceUpdateResponse) error + PerformAccountDeactivation(ctx context.Context, req *PerformAccountDeactivationRequest, res *PerformAccountDeactivationResponse) error QueryProfile(ctx context.Context, req *QueryProfileRequest, res *QueryProfileResponse) error QueryAccessToken(ctx context.Context, req *QueryAccessTokenRequest, res *QueryAccessTokenResponse) error QueryDevices(ctx context.Context, req *QueryDevicesRequest, res *QueryDevicesResponse) error @@ -203,6 +204,16 @@ type PerformDeviceCreationResponse struct { Device *Device } +// PerformAccountDeactivationRequest is the request for PerformAccountDeactivation +type PerformAccountDeactivationRequest struct { + Localpart string +} + +// PerformAccountDeactivationResponse is the response for PerformAccountDeactivation +type PerformAccountDeactivationResponse struct { + AccountDeactivated bool +} + // Device represents a client's device (mobile, web, etc) type Device struct { ID string diff --git a/userapi/internal/api.go b/userapi/internal/api.go index fd022876d..81d002414 100644 --- a/userapi/internal/api.go +++ b/userapi/internal/api.go @@ -388,3 +388,10 @@ func (a *UserInternalAPI) queryAppServiceToken(ctx context.Context, token, appSe dev.UserID = appService.SenderLocalpart return &dev, nil } + +// PerformAccountDeactivation deactivates the user's account, removing all ability for the user to login again. +func (a *UserInternalAPI) PerformAccountDeactivation(ctx context.Context, req *api.PerformAccountDeactivationRequest, res *api.PerformAccountDeactivationResponse) error { + err := a.AccountDB.DeactivateAccount(ctx, req.Localpart) + res.AccountDeactivated = err == nil + return err +} diff --git a/userapi/inthttp/client.go b/userapi/inthttp/client.go index 6dcaf7568..4d9dcc416 100644 --- a/userapi/inthttp/client.go +++ b/userapi/inthttp/client.go @@ -28,11 +28,12 @@ import ( const ( InputAccountDataPath = "/userapi/inputAccountData" - PerformDeviceCreationPath = "/userapi/performDeviceCreation" - PerformAccountCreationPath = "/userapi/performAccountCreation" - PerformPasswordUpdatePath = "/userapi/performPasswordUpdate" - PerformDeviceDeletionPath = "/userapi/performDeviceDeletion" - PerformDeviceUpdatePath = "/userapi/performDeviceUpdate" + PerformDeviceCreationPath = "/userapi/performDeviceCreation" + PerformAccountCreationPath = "/userapi/performAccountCreation" + PerformPasswordUpdatePath = "/userapi/performPasswordUpdate" + PerformDeviceDeletionPath = "/userapi/performDeviceDeletion" + PerformDeviceUpdatePath = "/userapi/performDeviceUpdate" + PerformAccountDeactivationPath = "/userapi/performAccountDeactivation" QueryProfilePath = "/userapi/queryProfile" QueryAccessTokenPath = "/userapi/queryAccessToken" @@ -126,6 +127,14 @@ func (h *httpUserInternalAPI) PerformDeviceUpdate(ctx context.Context, req *api. return httputil.PostJSON(ctx, span, h.httpClient, apiURL, req, res) } +func (h *httpUserInternalAPI) PerformAccountDeactivation(ctx context.Context, req *api.PerformAccountDeactivationRequest, res *api.PerformAccountDeactivationResponse) error { + span, ctx := opentracing.StartSpanFromContext(ctx, "PerformAccountDeactivation") + defer span.Finish() + + apiURL := h.apiURL + PerformAccountDeactivationPath + return httputil.PostJSON(ctx, span, h.httpClient, apiURL, req, res) +} + func (h *httpUserInternalAPI) QueryProfile( ctx context.Context, request *api.QueryProfileRequest, diff --git a/userapi/inthttp/server.go b/userapi/inthttp/server.go index d26746788..e24aad3a9 100644 --- a/userapi/inthttp/server.go +++ b/userapi/inthttp/server.go @@ -91,6 +91,19 @@ func AddRoutes(internalAPIMux *mux.Router, s api.UserInternalAPI) { return util.JSONResponse{Code: http.StatusOK, JSON: &response} }), ) + internalAPIMux.Handle(PerformAccountDeactivationPath, + httputil.MakeInternalAPI("performAccountDeactivation", func(req *http.Request) util.JSONResponse { + request := api.PerformAccountDeactivationRequest{} + response := api.PerformAccountDeactivationResponse{} + if err := json.NewDecoder(req.Body).Decode(&request); err != nil { + return util.MessageResponse(http.StatusBadRequest, err.Error()) + } + if err := s.PerformAccountDeactivation(req.Context(), &request, &response); err != nil { + return util.ErrorResponse(err) + } + return util.JSONResponse{Code: http.StatusOK, JSON: &response} + }), + ) internalAPIMux.Handle(QueryProfilePath, httputil.MakeInternalAPI("queryProfile", func(req *http.Request) util.JSONResponse { request := api.QueryProfileRequest{} diff --git a/userapi/storage/accounts/interface.go b/userapi/storage/accounts/interface.go index 49446f11f..c86b2c391 100644 --- a/userapi/storage/accounts/interface.go +++ b/userapi/storage/accounts/interface.go @@ -51,6 +51,7 @@ type Database interface { CheckAccountAvailability(ctx context.Context, localpart string) (bool, error) GetAccountByLocalpart(ctx context.Context, localpart string) (*api.Account, error) SearchProfiles(ctx context.Context, searchString string, limit int) ([]authtypes.Profile, error) + DeactivateAccount(ctx context.Context, localpart string) (err error) } // Err3PIDInUse is the error returned when trying to save an association involving diff --git a/userapi/storage/accounts/postgres/accounts_table.go b/userapi/storage/accounts/postgres/accounts_table.go index 7500e1e82..254da84c3 100644 --- a/userapi/storage/accounts/postgres/accounts_table.go +++ b/userapi/storage/accounts/postgres/accounts_table.go @@ -37,7 +37,9 @@ CREATE TABLE IF NOT EXISTS account_accounts ( -- The password hash for this account. Can be NULL if this is a passwordless account. password_hash TEXT, -- Identifies which application service this account belongs to, if any. - appservice_id TEXT + appservice_id TEXT, + -- If the account is currently active + is_deactivated BOOLEAN DEFAULT FALSE -- TODO: -- is_guest, is_admin, upgraded_ts, devices, any email reset stuff? ); @@ -51,11 +53,14 @@ const insertAccountSQL = "" + const updatePasswordSQL = "" + "UPDATE account_accounts SET password_hash = $1 WHERE localpart = $2" +const deactivateAccountSQL = "" + + "UPDATE account_accounts SET is_deactivated = TRUE WHERE localpart = $1" + const selectAccountByLocalpartSQL = "" + "SELECT localpart, appservice_id FROM account_accounts WHERE localpart = $1" const selectPasswordHashSQL = "" + - "SELECT password_hash FROM account_accounts WHERE localpart = $1" + "SELECT password_hash FROM account_accounts WHERE localpart = $1 AND is_deactivated = FALSE" const selectNewNumericLocalpartSQL = "" + "SELECT nextval('numeric_username_seq')" @@ -63,6 +68,7 @@ const selectNewNumericLocalpartSQL = "" + type accountsStatements struct { insertAccountStmt *sql.Stmt updatePasswordStmt *sql.Stmt + deactivateAccountStmt *sql.Stmt selectAccountByLocalpartStmt *sql.Stmt selectPasswordHashStmt *sql.Stmt selectNewNumericLocalpartStmt *sql.Stmt @@ -80,6 +86,9 @@ func (s *accountsStatements) prepare(db *sql.DB, server gomatrixserverlib.Server if s.updatePasswordStmt, err = db.Prepare(updatePasswordSQL); err != nil { return } + if s.deactivateAccountStmt, err = db.Prepare(deactivateAccountSQL); err != nil { + return + } if s.selectAccountByLocalpartStmt, err = db.Prepare(selectAccountByLocalpartSQL); err != nil { return } @@ -127,6 +136,13 @@ func (s *accountsStatements) updatePassword( return } +func (s *accountsStatements) deactivateAccount( + ctx context.Context, localpart string, +) (err error) { + _, err = s.deactivateAccountStmt.ExecContext(ctx, localpart) + return +} + func (s *accountsStatements) selectPasswordHash( ctx context.Context, localpart string, ) (hash string, err error) { diff --git a/userapi/storage/accounts/postgres/deltas/20200929203058_is_active.sql b/userapi/storage/accounts/postgres/deltas/20200929203058_is_active.sql new file mode 100644 index 000000000..32e6e1664 --- /dev/null +++ b/userapi/storage/accounts/postgres/deltas/20200929203058_is_active.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE account_accounts ADD COLUMN IF NOT EXISTS is_deactivated BOOLEAN DEFAULT FALSE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE account_accounts DROP COLUMN is_deactivated; +-- +goose StatementEnd diff --git a/userapi/storage/accounts/postgres/storage.go b/userapi/storage/accounts/postgres/storage.go index 8b9ebef80..2230f7e79 100644 --- a/userapi/storage/accounts/postgres/storage.go +++ b/userapi/storage/accounts/postgres/storage.go @@ -317,3 +317,8 @@ func (d *Database) SearchProfiles(ctx context.Context, searchString string, limi ) ([]authtypes.Profile, error) { return d.profiles.selectProfilesBySearch(ctx, searchString, limit) } + +// DeactivateAccount deactivates the user's account, removing all ability for the user to login again. +func (d *Database) DeactivateAccount(ctx context.Context, localpart string) (err error) { + return d.accounts.deactivateAccount(ctx, localpart) +} diff --git a/userapi/storage/accounts/sqlite3/accounts_table.go b/userapi/storage/accounts/sqlite3/accounts_table.go index 2d935fb63..d0ea8a8bc 100644 --- a/userapi/storage/accounts/sqlite3/accounts_table.go +++ b/userapi/storage/accounts/sqlite3/accounts_table.go @@ -37,7 +37,9 @@ CREATE TABLE IF NOT EXISTS account_accounts ( -- The password hash for this account. Can be NULL if this is a passwordless account. password_hash TEXT, -- Identifies which application service this account belongs to, if any. - appservice_id TEXT + appservice_id TEXT, + -- If the account is currently active + is_deactivated BOOLEAN DEFAULT 0 -- TODO: -- is_guest, is_admin, upgraded_ts, devices, any email reset stuff? ); @@ -49,11 +51,14 @@ const insertAccountSQL = "" + const updatePasswordSQL = "" + "UPDATE account_accounts SET password_hash = $1 WHERE localpart = $2" +const deactivateAccountSQL = "" + + "UPDATE account_accounts SET is_deactivated = 1 WHERE localpart = $1" + const selectAccountByLocalpartSQL = "" + "SELECT localpart, appservice_id FROM account_accounts WHERE localpart = $1" const selectPasswordHashSQL = "" + - "SELECT password_hash FROM account_accounts WHERE localpart = $1" + "SELECT password_hash FROM account_accounts WHERE localpart = $1 AND is_deactivated = 0" const selectNewNumericLocalpartSQL = "" + "SELECT COUNT(localpart) FROM account_accounts" @@ -62,6 +67,7 @@ type accountsStatements struct { db *sql.DB insertAccountStmt *sql.Stmt updatePasswordStmt *sql.Stmt + deactivateAccountStmt *sql.Stmt selectAccountByLocalpartStmt *sql.Stmt selectPasswordHashStmt *sql.Stmt selectNewNumericLocalpartStmt *sql.Stmt @@ -81,6 +87,9 @@ func (s *accountsStatements) prepare(db *sql.DB, server gomatrixserverlib.Server if s.updatePasswordStmt, err = db.Prepare(updatePasswordSQL); err != nil { return } + if s.deactivateAccountStmt, err = db.Prepare(deactivateAccountSQL); err != nil { + return + } if s.selectAccountByLocalpartStmt, err = db.Prepare(selectAccountByLocalpartSQL); err != nil { return } @@ -128,6 +137,13 @@ func (s *accountsStatements) updatePassword( return } +func (s *accountsStatements) deactivateAccount( + ctx context.Context, localpart string, +) (err error) { + _, err = s.deactivateAccountStmt.ExecContext(ctx, localpart) + return +} + func (s *accountsStatements) selectPasswordHash( ctx context.Context, localpart string, ) (hash string, err error) { diff --git a/userapi/storage/accounts/sqlite3/deltas/20200929203058_is_active.sql b/userapi/storage/accounts/sqlite3/deltas/20200929203058_is_active.sql new file mode 100644 index 000000000..51e9bae3c --- /dev/null +++ b/userapi/storage/accounts/sqlite3/deltas/20200929203058_is_active.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE account_accounts RENAME TO account_accounts_tmp; +CREATE TABLE account_accounts ( + localpart TEXT NOT NULL PRIMARY KEY, + created_ts BIGINT NOT NULL, + password_hash TEXT, + appservice_id TEXT, + is_deactivated BOOLEAN DEFAULT 0 +); +INSERT + INTO account_accounts ( + localpart, created_ts, password_hash, appservice_id + ) SELECT + localpart, created_ts, password_hash, appservice_id + FROM account_accounts_tmp +; +DROP TABLE account_accounts_tmp; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE account_accounts RENAME TO account_accounts_tmp; +CREATE TABLE account_accounts ( + localpart TEXT NOT NULL PRIMARY KEY, + created_ts BIGINT NOT NULL, + password_hash TEXT, + appservice_id TEXT +); +INSERT + INTO account_accounts ( + localpart, created_ts, password_hash, appservice_id + ) SELECT + localpart, created_ts, password_hash, appservice_id + FROM account_accounts_tmp +; +DROP TABLE account_accounts_tmp; +-- +goose StatementEnd diff --git a/userapi/storage/accounts/sqlite3/storage.go b/userapi/storage/accounts/sqlite3/storage.go index 4b66304c2..7a2830a93 100644 --- a/userapi/storage/accounts/sqlite3/storage.go +++ b/userapi/storage/accounts/sqlite3/storage.go @@ -359,3 +359,8 @@ func (d *Database) SearchProfiles(ctx context.Context, searchString string, limi ) ([]authtypes.Profile, error) { return d.profiles.selectProfilesBySearch(ctx, searchString, limit) } + +// DeactivateAccount deactivates the user's account, removing all ability for the user to login again. +func (d *Database) DeactivateAccount(ctx context.Context, localpart string) (err error) { + return d.accounts.deactivateAccount(ctx, localpart) +}