feat: implement api for "forgot password?" flow (#14915)

Relates to https://github.com/coder/coder/issues/14232

This implements two endpoints (names subject to change):
- `/api/v2/users/otp/request`
- `/api/v2/users/otp/change-password`
This commit is contained in:
Danielle Maywood
2024-10-04 11:53:25 +01:00
committed by GitHub
parent 8785a51b09
commit 4369f2b4b5
25 changed files with 1007 additions and 4 deletions
+88
View File
@@ -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": {
+74
View File
@@ -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": {
+8
View File
@@ -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(
+8
View File
@@ -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,
}
}
+3 -1
View File
@@ -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")
+8
View File
@@ -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 {
+6
View File
@@ -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{
+22
View File
@@ -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
}
+7
View File
@@ -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)
+14
View File
@@ -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()
@@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = '62f86a30-2330-4b61-a26d-311ff3b608cf';
@@ -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);
+1
View File
@@ -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)
+24 -1
View File
@@ -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
`
+13 -1
View File
@@ -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
;
+2
View File
@@ -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.
@@ -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)
@@ -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.
@@ -0,0 +1 @@
Your One-Time Passcode for Coder.
+235 -1
View File
@@ -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
+321
View File
@@ -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
+40
View File
@@ -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.
+66
View File
@@ -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
+32
View File
@@ -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
+12
View File
@@ -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;