From 40f3fc3a1c7ef613537be2b4064d513c4cc28925 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 10 Aug 2023 20:04:35 -0500 Subject: [PATCH] feat: allow creating manual oidc/github based users (#9000) * feat: allow creating manual oidc/github based users * Add unit test for oidc and no login type create --- cli/testdata/coder_users_create_--help.golden | 12 +-- cli/usercreate.go | 44 +++++++-- coderd/apidoc/docs.go | 12 ++- coderd/apidoc/swagger.json | 13 ++- coderd/coderdtest/coderdtest.go | 16 ++-- coderd/userauth_test.go | 18 +++- coderd/users.go | 40 ++++++--- coderd/users_test.go | 66 ++++++++++++++ codersdk/apikey.go | 1 + codersdk/users.go | 5 +- docs/api/audit.md | 2 +- docs/api/authorization.md | 4 +- docs/api/enterprise.md | 23 ++--- docs/api/schemas.md | 41 +++++---- docs/api/users.md | 21 ++--- docs/cli/users_create.md | 16 ++-- scripts/develop.sh | 2 +- site/src/api/typesGenerated.ts | 4 +- .../CreateUserForm/CreateUserForm.tsx | 89 ++++++++++++++++++- .../CreateUserPage/CreateUserPage.test.tsx | 11 ++- .../CreateUserPage/CreateUserPage.tsx | 10 +++ 21 files changed, 356 insertions(+), 94 deletions(-) diff --git a/cli/testdata/coder_users_create_--help.golden b/cli/testdata/coder_users_create_--help.golden index bb94cac633..275e89803d 100644 --- a/cli/testdata/coder_users_create_--help.golden +++ b/cli/testdata/coder_users_create_--help.golden @@ -1,15 +1,15 @@ Usage: coder users create [flags] Options - --disable-login bool - Disabling login for a user prevents the user from authenticating via - password or IdP login. Authentication requires an API key/token - generated by an admin. Be careful when using this flag as it can lock - the user out of their account. - -e, --email string Specifies an email address for the new user. + --login-type string + Optionally specify the login type for the user. Valid values are: + password, none, github, oidc. Using 'none' prevents the user from + authenticating and requires an API key/token to be generated by an + admin. + -p, --password string Specifies a password for the new user. diff --git a/cli/usercreate.go b/cli/usercreate.go index b38bbb2d64..80118d7fce 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "golang.org/x/xerrors" @@ -18,6 +19,7 @@ func (r *RootCmd) userCreate() *clibase.Cmd { username string password string disableLogin bool + loginType string ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -54,7 +56,18 @@ func (r *RootCmd) userCreate() *clibase.Cmd { return err } } - if password == "" && !disableLogin { + userLoginType := codersdk.LoginTypePassword + if disableLogin && loginType != "" { + return xerrors.New("You cannot specify both --disable-login and --login-type") + } + if disableLogin { + userLoginType = codersdk.LoginTypeNone + } else if loginType != "" { + userLoginType = codersdk.LoginType(loginType) + } + + if password == "" && userLoginType == codersdk.LoginTypePassword { + // Generate a random password password, err = cryptorand.StringCharset(cryptorand.Human, 20) if err != nil { return err @@ -66,14 +79,22 @@ func (r *RootCmd) userCreate() *clibase.Cmd { Username: username, Password: password, OrganizationID: organization.ID, - DisableLogin: disableLogin, + UserLoginType: userLoginType, }) if err != nil { return err } - authenticationMethod := `Your password is: ` + cliui.DefaultStyles.Field.Render(password) - if disableLogin { + + authenticationMethod := "" + switch codersdk.LoginType(strings.ToLower(string(userLoginType))) { + case codersdk.LoginTypePassword: + authenticationMethod = `Your password is: ` + cliui.DefaultStyles.Field.Render(password) + case codersdk.LoginTypeNone: authenticationMethod = "Login has been disabled for this user. Contact your administrator to authenticate." + case codersdk.LoginTypeGithub: + authenticationMethod = `Login is authenticated through GitHub.` + case codersdk.LoginTypeOIDC: + authenticationMethod = `Login is authenticated through the configured OIDC provider.` } _, _ = fmt.Fprintln(inv.Stderr, `A new user has been created! @@ -111,11 +132,22 @@ Create a workspace `+cliui.DefaultStyles.Code.Render("coder create")+`!`) Value: clibase.StringOf(&password), }, { - Flag: "disable-login", - Description: "Disabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. " + + Flag: "disable-login", + Hidden: true, + Description: "Deprecated: Use '--login-type=none'. \nDisabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. " + "Be careful when using this flag as it can lock the user out of their account.", Value: clibase.BoolOf(&disableLogin), }, + { + Flag: "login-type", + Description: fmt.Sprintf("Optionally specify the login type for the user. Valid values are: %s. "+ + "Using 'none' prevents the user from authenticating and requires an API key/token to be generated by an admin.", + strings.Join([]string{ + string(codersdk.LoginTypePassword), string(codersdk.LoginTypeNone), string(codersdk.LoginTypeGithub), string(codersdk.LoginTypeOIDC), + }, ", ", + )), + Value: clibase.StringOf(&loginType), + }, } return cmd } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6d590b02d4..2f04b8be2a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7536,13 +7536,21 @@ const docTemplate = `{ ], "properties": { "disable_login": { - "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.", + "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.\nDeprecated: Set UserLoginType=LoginTypeDisabled instead.", "type": "boolean" }, "email": { "type": "string", "format": "email" }, + "login_type": { + "description": "UserLoginType defaults to LoginTypePassword.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.LoginType" + } + ] + }, "organization_id": { "type": "string", "format": "uuid" @@ -8449,6 +8457,7 @@ const docTemplate = `{ "codersdk.LoginType": { "type": "string", "enum": [ + "", "password", "github", "oidc", @@ -8456,6 +8465,7 @@ const docTemplate = `{ "none" ], "x-enum-varnames": [ + "LoginTypeUnknown", "LoginTypePassword", "LoginTypeGithub", "LoginTypeOIDC", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 09aef2e087..997bdca3ad 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6715,13 +6715,21 @@ "required": ["email", "username"], "properties": { "disable_login": { - "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.", + "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.\nDeprecated: Set UserLoginType=LoginTypeDisabled instead.", "type": "boolean" }, "email": { "type": "string", "format": "email" }, + "login_type": { + "description": "UserLoginType defaults to LoginTypePassword.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.LoginType" + } + ] + }, "organization_id": { "type": "string", "format": "uuid" @@ -7576,8 +7584,9 @@ }, "codersdk.LoginType": { "type": "string", - "enum": ["password", "github", "oidc", "token", "none"], + "enum": ["", "password", "github", "oidc", "token", "none"], "x-enum-varnames": [ + "LoginTypeUnknown", "LoginTypePassword", "LoginTypeGithub", "LoginTypeOIDC", diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 5e2e55d5c0..0447050968 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -588,14 +588,7 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI require.NoError(t, err) var sessionToken string - if !req.DisableLogin { - login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ - Email: req.Email, - Password: req.Password, - }) - require.NoError(t, err) - sessionToken = login.SessionToken - } else { + if req.DisableLogin || req.UserLoginType == codersdk.LoginTypeNone { // Cannot log in with a disabled login user. So make it an api key from // the client making this user. token, err := client.CreateToken(context.Background(), user.ID.String(), codersdk.CreateTokenRequest{ @@ -605,6 +598,13 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI }) require.NoError(t, err) sessionToken = token.Key + } else { + login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ + Email: req.Email, + Password: req.Password, + }) + require.NoError(t, err) + sessionToken = login.SessionToken } if user.Status == codersdk.UserStatusDormant { diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index efa7673890..8910bce286 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -145,7 +145,7 @@ func TestUserLogin(t *testing.T) { require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) }) // Password auth should fail if the user is made without password login. - t.Run("LoginTypeNone", func(t *testing.T) { + t.Run("DisableLoginDeprecatedField", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -160,6 +160,22 @@ func TestUserLogin(t *testing.T) { }) require.Error(t, err) }) + + t.Run("LoginTypeNone", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + r.Password = "" + r.UserLoginType = codersdk.LoginTypeNone + }) + + _, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ + Email: anotherUser.Email, + Password: "SomeSecurePassword!", + }) + require.Error(t, err) + }) } func TestUserAuthMethods(t *testing.T) { diff --git a/coderd/users.go b/coderd/users.go index b34b447b8c..29a6d56015 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -287,11 +287,27 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } + if req.UserLoginType == "" && req.DisableLogin { + // Handle the deprecated field + req.UserLoginType = codersdk.LoginTypeNone + } + if req.UserLoginType == "" { + // Default to password auth + req.UserLoginType = codersdk.LoginTypePassword + } + + if req.UserLoginType != codersdk.LoginTypePassword && req.Password != "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Password cannot be set for non-password (%q) authentication.", req.UserLoginType), + }) + return + } + // If password auth is disabled, don't allow new users to be // created with a password! - if api.DeploymentValues.DisablePasswordAuth { + if api.DeploymentValues.DisablePasswordAuth && req.UserLoginType == codersdk.LoginTypePassword { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "You cannot manually provision new users with password authentication disabled!", + Message: "Password based authentication is disabled! Unable to provision new users with password authentication.", }) return } @@ -353,17 +369,11 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { } } - if req.DisableLogin && req.Password != "" { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Cannot set password when disabling login.", - }) - return - } - var loginType database.LoginType - if req.DisableLogin { + switch req.UserLoginType { + case codersdk.LoginTypeNone: loginType = database.LoginTypeNone - } else { + case codersdk.LoginTypePassword: err = userpassword.Validate(req.Password) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -376,6 +386,14 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } loginType = database.LoginTypePassword + case codersdk.LoginTypeOIDC: + loginType = database.LoginTypeOIDC + case codersdk.LoginTypeGithub: + loginType = database.LoginTypeGithub + default: + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Unsupported login type %q for manually creating new users.", req.UserLoginType), + }) } user, _, err := api.CreateUser(ctx, api.Database, CreateUserRequest{ diff --git a/coderd/users_test.go b/coderd/users_test.go index 9b130133cd..c2564e2ff8 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/golang-jwt/jwt" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -566,6 +567,71 @@ func TestPostUsers(t *testing.T) { } } }) + + t.Run("CreateNoneLoginType", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: first.OrganizationID, + Email: "another@user.org", + Username: "someone-else", + Password: "", + UserLoginType: codersdk.LoginTypeNone, + }) + require.NoError(t, err) + + found, err := client.User(ctx, user.ID.String()) + require.NoError(t, err) + require.Equal(t, found.LoginType, codersdk.LoginTypeNone) + }) + + t.Run("CreateOIDCLoginType", func(t *testing.T) { + t.Parallel() + email := "another@user.org" + conf := coderdtest.NewOIDCConfig(t, "") + config := conf.OIDCConfig(t, jwt.MapClaims{ + "email": email, + }) + config.AllowSignups = false + config.IgnoreUserInfo = true + + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: config, + }) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: first.OrganizationID, + Email: email, + Username: "someone-else", + Password: "", + UserLoginType: codersdk.LoginTypeOIDC, + }) + require.NoError(t, err) + + // Try to log in with OIDC. + userClient := codersdk.New(client.URL) + resp := oidcCallback(t, userClient, conf.EncodeClaims(t, jwt.MapClaims{ + "email": email, + })) + require.Equal(t, resp.StatusCode, http.StatusTemporaryRedirect) + // Set the client to use this OIDC context + authCookie := authCookieValue(resp.Cookies()) + userClient.SetSessionToken(authCookie) + _ = resp.Body.Close() + + found, err := userClient.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, found.LoginType, codersdk.LoginTypeOIDC) + }) } func TestUpdateUserProfile(t *testing.T) { diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 514b519f5f..32c97cf538 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -28,6 +28,7 @@ type APIKey struct { type LoginType string const ( + LoginTypeUnknown LoginType = "" LoginTypePassword LoginType = "password" LoginTypeGithub LoginType = "github" LoginTypeOIDC LoginType = "oidc" diff --git a/codersdk/users.go b/codersdk/users.go index daeefee5f1..c11846ebda 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -78,9 +78,12 @@ type CreateFirstUserResponse struct { type CreateUserRequest struct { Email string `json:"email" validate:"required,email" format:"email"` Username string `json:"username" validate:"required,username"` - Password string `json:"password" validate:"required_if=DisableLogin false"` + Password string `json:"password"` + // UserLoginType defaults to LoginTypePassword. + UserLoginType LoginType `json:"login_type"` // DisableLogin sets the user's login type to 'none'. This prevents the user // from being able to use a password or any other authentication method to login. + // Deprecated: Set UserLoginType=LoginTypeDisabled instead. DisableLogin bool `json:"disable_login"` OrganizationID uuid.UUID `json:"organization_id" validate:"" format:"uuid"` } diff --git a/docs/api/audit.md b/docs/api/audit.md index d5aeb78665..5efe1f3410 100644 --- a/docs/api/audit.md +++ b/docs/api/audit.md @@ -63,7 +63,7 @@ curl -X GET http://coder-server:8080/api/v2/audit?q=string \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { diff --git a/docs/api/authorization.md b/docs/api/authorization.md index d57a5e7542..17fc2e81d2 100644 --- a/docs/api/authorization.md +++ b/docs/api/authorization.md @@ -129,7 +129,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/convert-login \ ```json { "password": "string", - "to_type": "password" + "to_type": "" } ``` @@ -148,7 +148,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/convert-login \ { "expires_at": "2019-08-24T14:15:22Z", "state_string": "string", - "to_type": "password", + "to_type": "", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } ``` diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index fc887cd12b..15ba8c12b4 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -183,7 +183,7 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -245,7 +245,7 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -307,7 +307,7 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -444,7 +444,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -502,6 +502,7 @@ Status Code **200** | Property | Value | | ------------ | ----------- | +| `login_type` | `` | | `login_type` | `password` | | `login_type` | `github` | | `login_type` | `oidc` | @@ -562,7 +563,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -625,7 +626,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -988,7 +989,7 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1040,7 +1041,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "role": "admin", "roles": [ @@ -1086,6 +1087,7 @@ Status Code **200** | Property | Value | | ------------ | ----------- | +| `login_type` | `` | | `login_type` | `password` | | `login_type` | `github` | | `login_type` | `oidc` | @@ -1197,7 +1199,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1222,7 +1224,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1278,6 +1280,7 @@ Status Code **200** | Property | Value | | ------------ | ----------- | +| `login_type` | `` | | `login_type` | `password` | | `login_type` | `github` | | `login_type` | `oidc` | diff --git a/docs/api/schemas.md b/docs/api/schemas.md index cfcf28701a..11aad4ba5f 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -792,7 +792,7 @@ _None_ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -817,7 +817,7 @@ _None_ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1086,7 +1086,7 @@ _None_ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1163,7 +1163,7 @@ _None_ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1388,7 +1388,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "password": "string", - "to_type": "password" + "to_type": "" } ``` @@ -1665,6 +1665,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "disable_login": true, "email": "user@example.com", + "login_type": "", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "password": "string", "username": "string" @@ -1673,13 +1674,14 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------- | ------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disable_login` | boolean | false | | Disable login sets the user's login type to 'none'. This prevents the user from being able to use a password or any other authentication method to login. | -| `email` | string | true | | | -| `organization_id` | string | false | | | -| `password` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ----------------- | ---------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `disable_login` | boolean | false | | Disable login sets the user's login type to 'none'. This prevents the user from being able to use a password or any other authentication method to login. Deprecated: Set UserLoginType=LoginTypeDisabled instead. | +| `email` | string | true | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. | +| `organization_id` | string | false | | | +| `password` | string | false | | | +| `username` | string | true | | | ## codersdk.CreateWorkspaceBuildRequest @@ -2752,7 +2754,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -2970,7 +2972,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -3188,7 +3190,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ## codersdk.LoginType ```json -"password" +"" ``` ### Properties @@ -3197,6 +3199,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Value | | ---------- | +| `` | | `password` | | `github` | | `oidc` | @@ -3305,7 +3308,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "expires_at": "2019-08-24T14:15:22Z", "state_string": "string", - "to_type": "password", + "to_type": "", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } ``` @@ -4563,7 +4566,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "role": "admin", "roles": [ @@ -5071,7 +5074,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -5196,7 +5199,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { - "login_type": "password" + "login_type": "" } ``` diff --git a/docs/api/users.md b/docs/api/users.md index 3c583e1578..fdeed691da 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -36,7 +36,7 @@ curl -X GET http://coder-server:8080/api/v2/users \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -79,6 +79,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ { "disable_login": true, "email": "user@example.com", + "login_type": "", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "password": "string", "username": "string" @@ -102,7 +103,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -360,7 +361,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -411,7 +412,7 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -821,7 +822,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/login-type \ ```json { - "login_type": "password" + "login_type": "" } ``` @@ -1005,7 +1006,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/profile \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1056,7 +1057,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/roles \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1117,7 +1118,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/roles \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1168,7 +1169,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/activate \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1219,7 +1220,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/suspend \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { diff --git a/docs/cli/users_create.md b/docs/cli/users_create.md index 2eb78318ff..b89ff2aeb6 100644 --- a/docs/cli/users_create.md +++ b/docs/cli/users_create.md @@ -10,14 +10,6 @@ coder users create [flags] ## Options -### --disable-login - -| | | -| ---- | ----------------- | -| Type | bool | - -Disabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. Be careful when using this flag as it can lock the user out of their account. - ### -e, --email | | | @@ -26,6 +18,14 @@ Disabling login for a user prevents the user from authenticating via password or Specifies an email address for the new user. +### --login-type + +| | | +| ---- | ------------------- | +| Type | string | + +Optionally specify the login type for the user. Valid values are: password, none, github, oidc. Using 'none' prevents the user from authenticating and requires an API key/token to be generated by an admin. + ### -p, --password | | | diff --git a/scripts/develop.sh b/scripts/develop.sh index 671c46a0bd..cc1ab23f05 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -131,7 +131,7 @@ fatal() { trap 'fatal "Script encountered an error"' ERR cdroot - start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "http://127.0.0.1:3000" --dangerous-allow-cors-requests=true --experiments "*,moons" "$@" + start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "http://127.0.0.1:3000" --dangerous-allow-cors-requests=true "$@" echo '== Waiting for Coder to become ready' # Start the timeout in the background so interrupting this script diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c1aa96d872..019fc0f60b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -247,6 +247,7 @@ export interface CreateUserRequest { readonly email: string readonly username: string readonly password: string + readonly login_type: LoginType readonly disable_login: boolean readonly organization_id: string } @@ -1674,8 +1675,9 @@ export type LogSource = "provisioner" | "provisioner_daemon" export const LogSources: LogSource[] = ["provisioner", "provisioner_daemon"] // From codersdk/apikey.go -export type LoginType = "github" | "none" | "oidc" | "password" | "token" +export type LoginType = "" | "github" | "none" | "oidc" | "password" | "token" export const LoginTypes: LoginType[] = [ + "", "github", "none", "oidc", diff --git a/site/src/components/CreateUserForm/CreateUserForm.tsx b/site/src/components/CreateUserForm/CreateUserForm.tsx index c2f03155e7..6270f0ca88 100644 --- a/site/src/components/CreateUserForm/CreateUserForm.tsx +++ b/site/src/components/CreateUserForm/CreateUserForm.tsx @@ -13,6 +13,7 @@ import { FullPageForm } from "../FullPageForm/FullPageForm" import { Stack } from "../Stack/Stack" import { ErrorAlert } from "components/Alert/ErrorAlert" import { hasApiFieldErrors, isApiError } from "api/errors" +import MenuItem from "@mui/material/MenuItem" export const Language = { emailLabel: "Email", @@ -31,6 +32,7 @@ export interface CreateUserFormProps { error?: unknown isLoading: boolean myOrgId: string + authMethods?: TypesGen.AuthMethods } const validationSchema = Yup.object({ @@ -38,13 +40,31 @@ const validationSchema = Yup.object({ .trim() .email(Language.emailInvalid) .required(Language.emailRequired), - password: Yup.string().required(Language.passwordRequired), + password: Yup.string().when("login_type", { + is: "password", + then: (schema) => schema.required(Language.passwordRequired), + otherwise: (schema) => schema, + }), username: nameValidator(Language.usernameLabel), }) +const authMethodSelect = ( + title: string, + value: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- future will use this + description: string, +) => { + return ( + + {title} + {/* TODO: Add description */} + + ) +} + export const CreateUserForm: FC< React.PropsWithChildren -> = ({ onSubmit, onCancel, error, isLoading, myOrgId }) => { +> = ({ onSubmit, onCancel, error, isLoading, myOrgId, authMethods }) => { const form: FormikContextType = useFormik({ initialValues: { @@ -53,6 +73,7 @@ export const CreateUserForm: FC< username: "", organization_id: myOrgId, disable_login: false, + login_type: "password", }, validationSchema, onSubmit, @@ -62,6 +83,42 @@ export const CreateUserForm: FC< error, ) + const methods = [] + if (authMethods?.password.enabled) { + methods.push( + authMethodSelect( + "Password", + "password", + "User can provide their email and password to login.", + ), + ) + } + if (authMethods?.oidc.enabled) { + methods.push( + authMethodSelect( + "OpenID Connect", + "oidc", + "Uses an OpenID connect provider to authenticate the user.", + ), + ) + } + if (authMethods?.github.enabled) { + methods.push( + authMethodSelect( + "Github", + "github", + "Uses github oauth to authenticate the user.", + ), + ) + } + methods.push( + authMethodSelect( + "None", + "none", + "User authentication is disabled. This user an only be used if an api token is created for them.", + ), + ) + return ( {isApiError(error) && !hasApiFieldErrors(error) && ( @@ -85,13 +142,39 @@ export const CreateUserForm: FC< label={Language.emailLabel} /> + { + if (e.target.value !== "password") { + await form.setFieldValue("password", "") + } + await form.setFieldValue("login_type", e.target.value) + }} + > + {methods} + diff --git a/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.test.tsx b/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.test.tsx index d342ace9bd..ceeb30528d 100644 --- a/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.test.tsx +++ b/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.test.tsx @@ -21,7 +21,7 @@ const renderCreateUserPage = async () => { const fillForm = async ({ username = "someuser", email = "someone@coder.com", - password = "password", + password = "SomeSecurePassword!", }: { username?: string email?: string @@ -29,10 +29,15 @@ const fillForm = async ({ }) => { const usernameField = screen.getByLabelText(FormLanguage.usernameLabel) const emailField = screen.getByLabelText(FormLanguage.emailLabel) - const passwordField = screen.getByLabelText(FormLanguage.passwordLabel) + const passwordField = screen + .getByTestId("password-input") + .querySelector("input") + + const loginTypeField = screen.getByTestId("login-type-input") await userEvent.type(usernameField, username) await userEvent.type(emailField, email) - await userEvent.type(passwordField, password) + await userEvent.type(loginTypeField, "password") + await userEvent.type(passwordField as HTMLElement, password) const submitButton = await screen.findByText( FooterLanguage.defaultSubmitLabel, ) diff --git a/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx b/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx index cd905c39d8..cd92b6bc81 100644 --- a/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx +++ b/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx @@ -8,6 +8,8 @@ import * as TypesGen from "../../../api/typesGenerated" import { CreateUserForm } from "../../../components/CreateUserForm/CreateUserForm" import { Margins } from "../../../components/Margins/Margins" import { pageTitle } from "../../../utils/page" +import { getAuthMethods } from "api/api" +import { useQuery } from "@tanstack/react-query" export const Language = { unknownError: "Oops, an unknown error occurred.", @@ -25,6 +27,13 @@ export const CreateUserPage: FC = () => { }) const { error } = createUserState.context + // TODO: We should probably place this somewhere else to reduce the number of calls. + // This would be called each time this page is loaded. + const { data: authMethods } = useQuery({ + queryKey: ["authMethods"], + queryFn: getAuthMethods, + }) + return ( @@ -33,6 +42,7 @@ export const CreateUserPage: FC = () => { createUserSend({ type: "CREATE", user }) }