diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a185cc66e168c..d2bc239f9310f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5253,6 +5253,62 @@ const docTemplate = `{ } } }, + "/users/otp/change-password": { + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Change password with a one-time passcode", + "operationId": "change-password-with-a-one-time-passcode", + "parameters": [ + { + "description": "Change password request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.ChangePasswordWithOneTimePasscodeRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/users/otp/request": { + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Request one-time passcode", + "operationId": "request-one-time-passcode", + "parameters": [ + { + "description": "One-time passcode request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.RequestOneTimePasscodeRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/users/roles": { "get": { "security": [ @@ -9293,6 +9349,26 @@ const docTemplate = `{ "BuildReasonAutostop" ] }, + "codersdk.ChangePasswordWithOneTimePasscodeRequest": { + "type": "object", + "required": [ + "email", + "one_time_passcode", + "password" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "one_time_passcode": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, "codersdk.ConnectionLatency": { "type": "object", "properties": { @@ -12306,6 +12382,18 @@ const docTemplate = `{ } } }, + "codersdk.RequestOneTimePasscodeRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + }, "codersdk.ResolveAutostartResponse": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 84d1b67760f88..260be731b53c2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4635,6 +4635,54 @@ } } }, + "/users/otp/change-password": { + "post": { + "consumes": ["application/json"], + "tags": ["Authorization"], + "summary": "Change password with a one-time passcode", + "operationId": "change-password-with-a-one-time-passcode", + "parameters": [ + { + "description": "Change password request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.ChangePasswordWithOneTimePasscodeRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/users/otp/request": { + "post": { + "consumes": ["application/json"], + "tags": ["Authorization"], + "summary": "Request one-time passcode", + "operationId": "request-one-time-passcode", + "parameters": [ + { + "description": "One-time passcode request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.RequestOneTimePasscodeRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/users/roles": { "get": { "security": [ @@ -8264,6 +8312,22 @@ "BuildReasonAutostop" ] }, + "codersdk.ChangePasswordWithOneTimePasscodeRequest": { + "type": "object", + "required": ["email", "one_time_passcode", "password"], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "one_time_passcode": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, "codersdk.ConnectionLatency": { "type": "object", "properties": { @@ -11122,6 +11186,16 @@ } } }, + "codersdk.RequestOneTimePasscodeRequest": { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + }, "codersdk.ResolveAutostartResponse": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index cbe008a726636..dc40554fbabae 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -248,6 +248,9 @@ type Options struct { // IDPSync holds all configured values for syncing external IDP users into Coder. IDPSync idpsync.IDPSync + + // OneTimePasscodeValidityPeriod specifies how long a one time passcode should be valid for. + OneTimePasscodeValidityPeriod time.Duration } // @title Coder API @@ -387,6 +390,9 @@ func New(options *Options) *API { v := schedule.NewAGPLUserQuietHoursScheduleStore() options.UserQuietHoursScheduleStore.Store(&v) } + if options.OneTimePasscodeValidityPeriod == 0 { + options.OneTimePasscodeValidityPeriod = 20 * time.Minute + } if options.StatsBatcher == nil { panic("developer error: options.StatsBatcher is nil") @@ -984,6 +990,8 @@ func New(options *Options) *API { // This value is intentionally increased during tests. r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute)) r.Post("/login", api.postLogin) + r.Post("/otp/request", api.postRequestOneTimePasscode) + r.Post("/otp/change-password", api.postChangePasswordWithOneTimePasscode) r.Route("/oauth2", func(r chi.Router) { r.Route("/github", func(r chi.Router) { r.Use( diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 21104167ad8dd..05c31f35bd20a 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -128,6 +128,9 @@ type Options struct { LoginRateLimit int FilesRateLimit int + // OneTimePasscodeValidityPeriod specifies how long a one time passcode should be valid for. + OneTimePasscodeValidityPeriod time.Duration + // IncludeProvisionerDaemon when true means to start an in-memory provisionerD IncludeProvisionerDaemon bool ProvisionerDaemonTags map[string]string @@ -311,6 +314,10 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can options.NotificationsEnqueuer = &testutil.FakeNotificationsEnqueuer{} } + if options.OneTimePasscodeValidityPeriod == 0 { + options.OneTimePasscodeValidityPeriod = testutil.WaitLong + } + var templateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore] if options.TemplateScheduleStore == nil { options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore() @@ -530,6 +537,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can DatabaseRolluper: options.DatabaseRolluper, WorkspaceUsageTracker: wuTracker, NotificationsEnqueuer: options.NotificationsEnqueuer, + OneTimePasscodeValidityPeriod: options.OneTimePasscodeValidityPeriod, } } diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index 1b5317e05ff4c..c0cbe54236124 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -303,7 +303,9 @@ func assertSecurityDefined(t *testing.T, comment SwaggerComment) { if comment.router == "/updatecheck" || comment.router == "/buildinfo" || comment.router == "/" || - comment.router == "/users/login" { + comment.router == "/users/login" || + comment.router == "/users/otp/request" || + comment.router == "/users/otp/change-password" { return // endpoints do not require authorization } assert.Equal(t, "CoderSessionToken", comment.security, "@Security must be equal CoderSessionToken") diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6436e7c6e3425..88cbf7ad90375 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3628,6 +3628,14 @@ func (q *querier) UpdateUserGithubComUserID(ctx context.Context, arg database.Up return q.db.UpdateUserGithubComUserID(ctx, arg) } +func (q *querier) UpdateUserHashedOneTimePasscode(ctx context.Context, arg database.UpdateUserHashedOneTimePasscodeParams) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { + return err + } + + return q.db.UpdateUserHashedOneTimePasscode(ctx, arg) +} + func (q *querier) UpdateUserHashedPassword(ctx context.Context, arg database.UpdateUserHashedPasswordParams) error { user, err := q.db.GetUserByID(ctx, arg.ID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index f3aec6c9326b0..4dda0bbf8122b 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1187,6 +1187,12 @@ func (s *MethodTestSuite) TestUser() { ID: u.ID, }).Asserts(u, policy.ActionUpdatePersonal).Returns() })) + s.Run("UpdateUserHashedOneTimePasscode", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + check.Args(database.UpdateUserHashedOneTimePasscodeParams{ + ID: u.ID, + }).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns() + })) s.Run("UpdateUserQuietHoursSchedule", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) check.Args(database.UpdateUserQuietHoursScheduleParams{ diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 09dfa3e7306db..2b43b80115724 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -9077,6 +9077,26 @@ func (q *FakeQuerier) UpdateUserGithubComUserID(_ context.Context, arg database. return sql.ErrNoRows } +func (q *FakeQuerier) UpdateUserHashedOneTimePasscode(_ context.Context, arg database.UpdateUserHashedOneTimePasscodeParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, user := range q.users { + if user.ID != arg.ID { + continue + } + user.HashedOneTimePasscode = arg.HashedOneTimePasscode + user.OneTimePasscodeExpiresAt = arg.OneTimePasscodeExpiresAt + q.users[i] = user + } + return nil +} + func (q *FakeQuerier) UpdateUserHashedPassword(_ context.Context, arg database.UpdateUserHashedPasswordParams) error { if err := validateDatabaseType(arg); err != nil { return err @@ -9090,6 +9110,8 @@ func (q *FakeQuerier) UpdateUserHashedPassword(_ context.Context, arg database.U continue } user.HashedPassword = arg.HashedPassword + user.HashedOneTimePasscode = nil + user.OneTimePasscodeExpiresAt = sql.NullTime{} q.users[i] = user return nil } diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index b050a4ce9afc4..93a5e0dabb2a5 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -2307,6 +2307,13 @@ func (m metricsStore) UpdateUserGithubComUserID(ctx context.Context, arg databas return r0 } +func (m metricsStore) UpdateUserHashedOneTimePasscode(ctx context.Context, arg database.UpdateUserHashedOneTimePasscodeParams) error { + start := time.Now() + r0 := m.s.UpdateUserHashedOneTimePasscode(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserHashedOneTimePasscode").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpdateUserHashedPassword(ctx context.Context, arg database.UpdateUserHashedPasswordParams) error { start := time.Now() err := m.s.UpdateUserHashedPassword(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 3c7dbd6d9b958..c75e45a6f91db 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4861,6 +4861,20 @@ func (mr *MockStoreMockRecorder) UpdateUserGithubComUserID(arg0, arg1 any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserGithubComUserID", reflect.TypeOf((*MockStore)(nil).UpdateUserGithubComUserID), arg0, arg1) } +// UpdateUserHashedOneTimePasscode mocks base method. +func (m *MockStore) UpdateUserHashedOneTimePasscode(arg0 context.Context, arg1 database.UpdateUserHashedOneTimePasscodeParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserHashedOneTimePasscode", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserHashedOneTimePasscode indicates an expected call of UpdateUserHashedOneTimePasscode. +func (mr *MockStoreMockRecorder) UpdateUserHashedOneTimePasscode(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserHashedOneTimePasscode", reflect.TypeOf((*MockStore)(nil).UpdateUserHashedOneTimePasscode), arg0, arg1) +} + // UpdateUserHashedPassword mocks base method. func (m *MockStore) UpdateUserHashedPassword(arg0 context.Context, arg1 database.UpdateUserHashedPasswordParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/migrations/000261_notifications_forgot_password.down.sql b/coderd/database/migrations/000261_notifications_forgot_password.down.sql new file mode 100644 index 0000000000000..3c85dc3887fbd --- /dev/null +++ b/coderd/database/migrations/000261_notifications_forgot_password.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = '62f86a30-2330-4b61-a26d-311ff3b608cf'; diff --git a/coderd/database/migrations/000261_notifications_forgot_password.up.sql b/coderd/database/migrations/000261_notifications_forgot_password.up.sql new file mode 100644 index 0000000000000..a5c1982be3d98 --- /dev/null +++ b/coderd/database/migrations/000261_notifications_forgot_password.up.sql @@ -0,0 +1,4 @@ +INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions) +VALUES ('62f86a30-2330-4b61-a26d-311ff3b608cf', 'One-Time Passcode', E'Your One-Time Passcode for Coder.', + E'Hi {{.UserName}},\n\nA request to reset the password for your Coder account has been made. Your one-time passcode is:\n\n**{{.Labels.one_time_passcode}}**\n\nIf you did not request to reset your password, you can ignore this message.', + 'User Events', '[]'::jsonb); diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d71c54e008350..de0a1a16a7e53 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -457,6 +457,7 @@ type sqlcQuerier interface { UpdateUserAppearanceSettings(ctx context.Context, arg UpdateUserAppearanceSettingsParams) (User, error) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error UpdateUserGithubComUserID(ctx context.Context, arg UpdateUserGithubComUserIDParams) error + UpdateUserHashedOneTimePasscode(ctx context.Context, arg UpdateUserHashedOneTimePasscodeParams) error UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f5b2943d1fa04..446fa479eeaff 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -10528,11 +10528,34 @@ func (q *sqlQuerier) UpdateUserGithubComUserID(ctx context.Context, arg UpdateUs return err } +const updateUserHashedOneTimePasscode = `-- name: UpdateUserHashedOneTimePasscode :exec +UPDATE + users +SET + hashed_one_time_passcode = $2, + one_time_passcode_expires_at = $3 +WHERE + id = $1 +` + +type UpdateUserHashedOneTimePasscodeParams struct { + ID uuid.UUID `db:"id" json:"id"` + HashedOneTimePasscode []byte `db:"hashed_one_time_passcode" json:"hashed_one_time_passcode"` + OneTimePasscodeExpiresAt sql.NullTime `db:"one_time_passcode_expires_at" json:"one_time_passcode_expires_at"` +} + +func (q *sqlQuerier) UpdateUserHashedOneTimePasscode(ctx context.Context, arg UpdateUserHashedOneTimePasscodeParams) error { + _, err := q.db.ExecContext(ctx, updateUserHashedOneTimePasscode, arg.ID, arg.HashedOneTimePasscode, arg.OneTimePasscodeExpiresAt) + return err +} + const updateUserHashedPassword = `-- name: UpdateUserHashedPassword :exec UPDATE users SET - hashed_password = $2 + hashed_password = $2, + hashed_one_time_passcode = NULL, + one_time_passcode_expires_at = NULL WHERE id = $1 ` diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 44148eb936a33..013e2b8027a45 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -117,7 +117,9 @@ RETURNING *; UPDATE users SET - hashed_password = $2 + hashed_password = $2, + hashed_one_time_passcode = NULL, + one_time_passcode_expires_at = NULL WHERE id = $1; @@ -289,3 +291,13 @@ RETURNING id, email, last_seen_at; -- AllUserIDs returns all UserIDs regardless of user status or deletion. -- name: AllUserIDs :many SELECT DISTINCT id FROM USERS; + +-- name: UpdateUserHashedOneTimePasscode :exec +UPDATE + users +SET + hashed_one_time_passcode = $2, + one_time_passcode_expires_at = $3 +WHERE + id = $1 +; diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 43406c3012317..c2e0f442e0623 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -24,6 +24,8 @@ var ( TemplateUserAccountActivated = uuid.MustParse("9f5af851-8408-4e73-a7a1-c6502ba46689") TemplateYourAccountSuspended = uuid.MustParse("6a2f0609-9b69-4d36-a989-9f5925b6cbff") TemplateYourAccountActivated = uuid.MustParse("1a6a6bea-ee0a-43e2-9e7c-eabdb53730e4") + + TemplateUserRequestedOneTimePasscode = uuid.MustParse("62f86a30-2330-4b61-a26d-311ff3b608cf") ) // Template-related events. diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index e52610c9c5823..1878fcdd45007 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -895,6 +895,16 @@ func TestNotificationTemplatesCanRender(t *testing.T) { }, }, }, + { + name: "TemplateUserRequestedOneTimePasscode", + id: notifications.TemplateUserRequestedOneTimePasscode, + payload: types.MessagePayload{ + UserName: "Bobby", + Labels: map[string]string{ + "one_time_passcode": "fad9020b-6562-4cdb-87f1-0486f1bea415", + }, + }, + }, } allTemplates, err := enumerateAllTemplates(t) diff --git a/coderd/notifications/testdata/rendered-templates/TemplateUserRequestedOneTimePasscode-body.md.golden b/coderd/notifications/testdata/rendered-templates/TemplateUserRequestedOneTimePasscode-body.md.golden new file mode 100644 index 0000000000000..6288af33f867e --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/TemplateUserRequestedOneTimePasscode-body.md.golden @@ -0,0 +1,7 @@ +Hi Bobby, + +A request to reset the password for your Coder account has been made. Your one-time passcode is: + +**fad9020b-6562-4cdb-87f1-0486f1bea415** + +If you did not request to reset your password, you can ignore this message. \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/TemplateUserRequestedOneTimePasscode-title.md.golden b/coderd/notifications/testdata/rendered-templates/TemplateUserRequestedOneTimePasscode-title.md.golden new file mode 100644 index 0000000000000..ecf7383911053 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/TemplateUserRequestedOneTimePasscode-title.md.golden @@ -0,0 +1 @@ +Your One-Time Passcode for Coder. \ No newline at end of file diff --git a/coderd/userauth.go b/coderd/userauth.go index b0ef24ad978cf..a1e1252797de3 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -23,7 +23,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/v2/coderd/idpsync" "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/audit" @@ -33,6 +32,8 @@ import ( "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/idpsync" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/render" @@ -201,6 +202,239 @@ func (api *API) postConvertLoginType(rw http.ResponseWriter, r *http.Request) { }) } +// Requests a one-time passcode for a user. +// +// @Summary Request one-time passcode +// @ID request-one-time-passcode +// @Accept json +// @Tags Authorization +// @Param request body codersdk.RequestOneTimePasscodeRequest true "One-time passcode request" +// @Success 204 +// @Router /users/otp/request [post] +func (api *API) postRequestOneTimePasscode(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + auditor = api.Auditor.Load() + logger = api.Logger.Named(userAuthLoggerName) + aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + defer commitAudit() + + if api.DeploymentValues.DisablePasswordAuth { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Password authentication is disabled.", + }) + return + } + + var req codersdk.RequestOneTimePasscodeRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + defer func() { + // We always send the same response. If we give a more detailed response + // it would open us up to an enumeration attack. + rw.WriteHeader(http.StatusNoContent) + }() + + //nolint:gocritic // In order to request a one-time passcode, we need to get the user first - and can only do that in the system auth context. + user, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{ + Email: req.Email, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + logger.Error(ctx, "unable to get user by email", slog.Error(err)) + return + } + // We continue if err == sql.ErrNoRows to help prevent a timing-based attack. + aReq.Old = user + + passcode := uuid.New() + passcodeExpiresAt := dbtime.Now().Add(api.OneTimePasscodeValidityPeriod) + + hashedPasscode, err := userpassword.Hash(passcode.String()) + if err != nil { + logger.Error(ctx, "unable to hash passcode", slog.Error(err)) + return + } + + //nolint:gocritic // We need the system auth context to be able to save the one-time passcode. + err = api.Database.UpdateUserHashedOneTimePasscode(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedOneTimePasscodeParams{ + ID: user.ID, + HashedOneTimePasscode: []byte(hashedPasscode), + OneTimePasscodeExpiresAt: sql.NullTime{Time: passcodeExpiresAt, Valid: true}, + }) + if err != nil { + logger.Error(ctx, "unable to set user hashed one-time passcode", slog.Error(err)) + return + } + + auditUser := user + auditUser.HashedOneTimePasscode = []byte(hashedPasscode) + auditUser.OneTimePasscodeExpiresAt = sql.NullTime{Time: passcodeExpiresAt, Valid: true} + aReq.New = auditUser + + if user.ID != uuid.Nil { + // Send the one-time passcode to the user. + err = api.notifyUserRequestedOneTimePasscode(ctx, user, passcode.String()) + if err != nil { + logger.Error(ctx, "unable to notify user about one-time passcode request", slog.Error(err)) + } + } +} + +func (api *API) notifyUserRequestedOneTimePasscode(ctx context.Context, user database.User, passcode string) error { + _, err := api.NotificationsEnqueuer.Enqueue( + //nolint:gocritic // We need the system auth context to be able to send the user their one-time passcode. + dbauthz.AsSystemRestricted(ctx), + user.ID, + notifications.TemplateUserRequestedOneTimePasscode, + map[string]string{"one_time_passcode": passcode}, + "change-password-with-one-time-passcode", + user.ID, + ) + if err != nil { + return xerrors.Errorf("enqueue notification: %w", err) + } + + return nil +} + +// Change a users password with a one-time passcode. +// +// @Summary Change password with a one-time passcode +// @ID change-password-with-a-one-time-passcode +// @Accept json +// @Tags Authorization +// @Param request body codersdk.ChangePasswordWithOneTimePasscodeRequest true "Change password request" +// @Success 204 +// @Router /users/otp/change-password [post] +func (api *API) postChangePasswordWithOneTimePasscode(rw http.ResponseWriter, r *http.Request) { + var ( + err error + ctx = r.Context() + auditor = api.Auditor.Load() + logger = api.Logger.Named(userAuthLoggerName) + aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + defer commitAudit() + + if api.DeploymentValues.DisablePasswordAuth { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Password authentication is disabled.", + }) + return + } + + var req codersdk.ChangePasswordWithOneTimePasscodeRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if err := userpassword.Validate(req.Password); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid password.", + Validations: []codersdk.ValidationError{ + { + Field: "password", + Detail: err.Error(), + }, + }, + }) + return + } + + err = api.Database.InTx(func(tx database.Store) error { + //nolint:gocritic // In order to change a user's password, we need to get the user first - and can only do that in the system auth context. + user, err := tx.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{ + Email: req.Email, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + logger.Error(ctx, "unable to fetch user by email", slog.F("email", req.Email), slog.Error(err)) + return xerrors.Errorf("get user by email: %w", err) + } + // We continue if err == sql.ErrNoRows to help prevent a timing-based attack. + aReq.Old = user + + equal, err := userpassword.Compare(string(user.HashedOneTimePasscode), req.OneTimePasscode) + if err != nil { + logger.Error(ctx, "unable to compare one-time passcode", slog.Error(err)) + return xerrors.Errorf("compare one-time passcode: %w", err) + } + + now := dbtime.Now() + if !equal || now.After(user.OneTimePasscodeExpiresAt.Time) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Incorrect email or one-time passcode.", + }) + return nil + } + + equal, err = userpassword.Compare(string(user.HashedPassword), req.Password) + if err != nil { + logger.Error(ctx, "unable to compare password", slog.Error(err)) + return xerrors.Errorf("compare password: %w", err) + } + + if equal { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "New password cannot match old password.", + }) + return nil + } + + newHashedPassword, err := userpassword.Hash(req.Password) + if err != nil { + logger.Error(ctx, "unable to hash user's password", slog.Error(err)) + return xerrors.Errorf("hash user password: %w", err) + } + + //nolint:gocritic // We need the system auth context to be able to update the user's password. + err = tx.UpdateUserHashedPassword(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedPasswordParams{ + ID: user.ID, + HashedPassword: []byte(newHashedPassword), + }) + if err != nil { + logger.Error(ctx, "unable to delete user's hashed password", slog.Error(err)) + return xerrors.Errorf("update user hashed password: %w", err) + } + + //nolint:gocritic // We need the system auth context to be able to delete all API keys for the user. + err = tx.DeleteAPIKeysByUserID(dbauthz.AsSystemRestricted(ctx), user.ID) + if err != nil { + logger.Error(ctx, "unable to delete user's api keys", slog.Error(err)) + return xerrors.Errorf("delete api keys for user: %w", err) + } + + auditUser := user + auditUser.HashedPassword = []byte(newHashedPassword) + auditUser.OneTimePasscodeExpiresAt = sql.NullTime{} + auditUser.HashedOneTimePasscode = nil + aReq.New = auditUser + + rw.WriteHeader(http.StatusNoContent) + + return nil + }, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error.", + Detail: err.Error(), + }) + return + } +} + // Authenticates the user with an email and password. // // @Summary Log in user diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 6302bee390acd..20dfe7f723899 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -31,6 +31,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" @@ -1654,6 +1655,326 @@ func TestOIDCSkipIssuer(t *testing.T) { require.Equal(t, found.LoginType, codersdk.LoginTypeOIDC) } +func TestUserForgotPassword(t *testing.T) { + t.Parallel() + + const oldPassword = "SomeSecurePassword!" + const newPassword = "SomeNewSecurePassword!" + + requireOneTimePasscodeNotification := func(t *testing.T, notif *testutil.Notification, userID uuid.UUID) { + require.Equal(t, notifications.TemplateUserRequestedOneTimePasscode, notif.TemplateID) + require.Equal(t, userID, notif.UserID) + require.Equal(t, 1, len(notif.Targets)) + require.Equal(t, userID, notif.Targets[0]) + } + + requireCanLogin := func(t *testing.T, ctx context.Context, client *codersdk.Client, email string, password string) { + _, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: email, + Password: password, + }) + require.NoError(t, err) + } + + requireCannotLogin := func(t *testing.T, ctx context.Context, client *codersdk.Client, email string, password string) { + _, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: email, + Password: password, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Incorrect email or password.") + } + + requireRequestOneTimePasscode := func(t *testing.T, ctx context.Context, client *codersdk.Client, notifyEnq *testutil.FakeNotificationsEnqueuer, email string, userID uuid.UUID) string { + notifsSent := len(notifyEnq.Sent) + + err := client.RequestOneTimePasscode(ctx, codersdk.RequestOneTimePasscodeRequest{Email: email}) + require.NoError(t, err) + + require.Equal(t, notifsSent+1, len(notifyEnq.Sent)) + + notif := notifyEnq.Sent[notifsSent] + requireOneTimePasscodeNotification(t, notif, userID) + return notif.Labels["one_time_passcode"] + } + + requireChangePasswordWithOneTimePasscode := func(t *testing.T, ctx context.Context, client *codersdk.Client, email string, passcode string, password string) { + err := client.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{ + Email: email, + OneTimePasscode: passcode, + Password: password, + }) + require.NoError(t, err) + } + + t.Run("CanChangePassword", func(t *testing.T) { + t.Parallel() + + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + + client := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + // First try to login before changing our password. We expected this to error + // as we haven't change the password yet. + requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword) + + oneTimePasscode := requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID) + + requireChangePasswordWithOneTimePasscode(t, ctx, anotherClient, anotherUser.Email, oneTimePasscode, newPassword) + requireCanLogin(t, ctx, anotherClient, anotherUser.Email, newPassword) + + // We now need to check that the one-time passcode isn't valid. + err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{ + Email: anotherUser.Email, + OneTimePasscode: oneTimePasscode, + Password: newPassword + "!", + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Incorrect email or one-time passcode.") + + requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword+"!") + requireCanLogin(t, ctx, anotherClient, anotherUser.Email, newPassword) + }) + + t.Run("OneTimePasscodeExpires", func(t *testing.T) { + t.Parallel() + + const oneTimePasscodeValidityPeriod = 1 * time.Millisecond + + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + + client := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + OneTimePasscodeValidityPeriod: oneTimePasscodeValidityPeriod, + }) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + oneTimePasscode := requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID) + + // Wait for long enough so that the token expires + time.Sleep(oneTimePasscodeValidityPeriod + 1*time.Millisecond) + + // Try to change password with an expired one time passcode. + err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{ + Email: anotherUser.Email, + OneTimePasscode: oneTimePasscode, + Password: newPassword, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Incorrect email or one-time passcode.") + + // Ensure that the password was not changed. + requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword) + requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword) + }) + + t.Run("CannotChangePasswordWithoutRequestingOneTimePasscode", func(t *testing.T) { + t.Parallel() + + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + + client := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{ + Email: anotherUser.Email, + OneTimePasscode: uuid.New().String(), + Password: newPassword, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Incorrect email or one-time passcode") + + requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword) + requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword) + }) + + t.Run("CannotChangePasswordWithInvalidOneTimePasscode", func(t *testing.T) { + t.Parallel() + + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + + client := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + _ = requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID) + + err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{ + Email: anotherUser.Email, + OneTimePasscode: uuid.New().String(), // Use a different UUID to the one expected + Password: newPassword, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Incorrect email or one-time passcode") + + requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword) + requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword) + }) + + t.Run("CannotChangePasswordWithNoOneTimePasscode", func(t *testing.T) { + t.Parallel() + + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + + client := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + _ = requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID) + + err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{ + Email: anotherUser.Email, + OneTimePasscode: "", + Password: newPassword, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Validation failed.") + require.Equal(t, 1, len(apiErr.Validations)) + require.Equal(t, "one_time_passcode", apiErr.Validations[0].Field) + + requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword) + requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword) + }) + + t.Run("CannotChangePasswordWithWeakPassword", func(t *testing.T) { + t.Parallel() + + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + + client := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + oneTimePasscode := requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID) + + err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{ + Email: anotherUser.Email, + OneTimePasscode: oneTimePasscode, + Password: "notstrong", + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Invalid password.") + require.Equal(t, 1, len(apiErr.Validations)) + require.Equal(t, "password", apiErr.Validations[0].Field) + + requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, "notstrong") + requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword) + }) + + t.Run("CannotChangePasswordOfAnotherUser", func(t *testing.T) { + t.Parallel() + + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + + client := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + thirdClient, thirdUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + // Request a One-Time Passcode for `anotherUser` + oneTimePasscode := requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID) + + // Ensure we cannot change the password for `thirdUser` with `anotherUser`'s One-Time Passcode. + err := thirdClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{ + Email: thirdUser.Email, + OneTimePasscode: oneTimePasscode, + Password: newPassword, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Incorrect email or one-time passcode") + + requireCannotLogin(t, ctx, thirdClient, thirdUser.Email, newPassword) + requireCanLogin(t, ctx, thirdClient, thirdUser.Email, oldPassword) + requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword) + }) + + t.Run("GivenOKResponseWithInvalidEmail", func(t *testing.T) { + t.Parallel() + + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + + client := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + anotherClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + err := anotherClient.RequestOneTimePasscode(ctx, codersdk.RequestOneTimePasscodeRequest{ + Email: "not-a-member@coder.com", + }) + require.NoError(t, err) + + require.Equal(t, 1, len(notifyEnq.Sent)) + + notif := notifyEnq.Sent[0] + require.NotEqual(t, notifications.TemplateUserRequestedOneTimePasscode, notif.TemplateID) + }) +} + func oauth2Callback(t *testing.T, client *codersdk.Client, opts ...func(*http.Request)) *http.Response { client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse diff --git a/codersdk/users.go b/codersdk/users.go index e35803abeb15e..f57b8010f9229 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -243,6 +243,18 @@ type LoginWithPasswordResponse struct { SessionToken string `json:"session_token" validate:"required"` } +// RequestOneTimePasscodeRequest enables callers to request a one-time-passcode to change their password. +type RequestOneTimePasscodeRequest struct { + Email string `json:"email" validate:"required,email" format:"email"` +} + +// ChangePasswordWithOneTimePasscodeRequest enables callers to change their password when they've forgotten it. +type ChangePasswordWithOneTimePasscodeRequest struct { + Email string `json:"email" validate:"required,email" format:"email"` + Password string `json:"password" validate:"required"` + OneTimePasscode string `json:"one_time_passcode" validate:"required"` +} + type OAuthConversionResponse struct { StateString string `json:"state_string"` ExpiresAt time.Time `json:"expires_at" format:"date-time"` @@ -550,6 +562,34 @@ func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordReq return resp, nil } +func (c *Client) RequestOneTimePasscode(ctx context.Context, req RequestOneTimePasscodeRequest) error { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/otp/request", req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + + return nil +} + +func (c *Client) ChangePasswordWithOneTimePasscode(ctx context.Context, req ChangePasswordWithOneTimePasscodeRequest) error { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/otp/change-password", req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + + return nil +} + // ConvertLoginType will send a request to convert the user from password // based authentication to oauth based. The response has the oauth state code // to use in the oauth flow. diff --git a/docs/reference/api/authorization.md b/docs/reference/api/authorization.md index 537d7e6944830..86cee5d0fd727 100644 --- a/docs/reference/api/authorization.md +++ b/docs/reference/api/authorization.md @@ -112,6 +112,72 @@ curl -X POST http://coder-server:8080/api/v2/users/login \ | ------ | ------------------------------------------------------------ | ----------- | ---------------------------------------------------------------------------------- | | 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.LoginWithPasswordResponse](schemas.md#codersdkloginwithpasswordresponse) | +## Change password with a one-time passcode + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/users/otp/change-password \ + -H 'Content-Type: application/json' +``` + +`POST /users/otp/change-password` + +> Body parameter + +```json +{ + "email": "user@example.com", + "one_time_passcode": "string", + "password": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ---------------------------------------------------------------------------------------------------------------- | -------- | ----------------------- | +| `body` | body | [codersdk.ChangePasswordWithOneTimePasscodeRequest](schemas.md#codersdkchangepasswordwithonetimepasscoderequest) | true | Change password request | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +## Request one-time passcode + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/users/otp/request \ + -H 'Content-Type: application/json' +``` + +`POST /users/otp/request` + +> Body parameter + +```json +{ + "email": "user@example.com" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------------------------------------------------------------------------------------------ | -------- | ------------------------- | +| `body` | body | [codersdk.RequestOneTimePasscodeRequest](schemas.md#codersdkrequestonetimepasscoderequest) | true | One-time passcode request | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + ## Convert user from password to oauth authentication ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index bb756d1a7ea8f..9f4c9fb0cafd4 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -930,6 +930,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `autostart` | | `autostop` | +## codersdk.ChangePasswordWithOneTimePasscodeRequest + +```json +{ + "email": "user@example.com", + "one_time_passcode": "string", + "password": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------- | ------ | -------- | ------------ | ----------- | +| `email` | string | true | | | +| `one_time_passcode` | string | true | | | +| `password` | string | true | | | + ## codersdk.ConnectionLatency ```json @@ -4636,6 +4654,20 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `region_id` | integer | false | | Region ID is the region of the replica. | | `relay_address` | string | false | | Relay address is the accessible address to relay DERP connections. | +## codersdk.RequestOneTimePasscodeRequest + +```json +{ + "email": "user@example.com" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------- | ------ | -------- | ------------ | ----------- | +| `email` | string | true | | | + ## codersdk.ResolveAutostartResponse ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bd40045c8c45e..dad3feb6266d0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -178,6 +178,13 @@ export interface BuildInfoResponse { readonly deployment_id: string; } +// From codersdk/users.go +export interface ChangePasswordWithOneTimePasscodeRequest { + readonly email: string; + readonly password: string; + readonly one_time_passcode: string; +} + // From codersdk/insights.go export interface ConnectionLatency { readonly p50: number; @@ -1155,6 +1162,11 @@ export interface Replica { readonly database_latency: number; } +// From codersdk/users.go +export interface RequestOneTimePasscodeRequest { + readonly email: string; +} + // From codersdk/workspaces.go export interface ResolveAutostartResponse { readonly parameter_mismatch: boolean;