mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
Generated
+88
@@ -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": {
|
||||
|
||||
Generated
+74
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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
|
||||
;
|
||||
|
||||
@@ -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)
|
||||
|
||||
coderd/notifications/testdata/rendered-templates/TemplateUserRequestedOneTimePasscode-body.md.golden
Vendored
+7
@@ -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.
|
||||
+1
@@ -0,0 +1 @@
|
||||
Your One-Time Passcode for Coder.
|
||||
+235
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+66
@@ -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
|
||||
|
||||
Generated
+32
@@ -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
|
||||
|
||||
Generated
+12
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user