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
This commit is contained in:
Steven Masley 2023-08-10 20:04:35 -05:00 committed by GitHub
parent 6fd5344d0a
commit 40f3fc3a1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 356 additions and 94 deletions

View File

@ -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.

View File

@ -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
}

12
coderd/apidoc/docs.go generated
View File

@ -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",

View File

@ -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",

View File

@ -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 {

View File

@ -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) {

View File

@ -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{

View File

@ -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) {

View File

@ -28,6 +28,7 @@ type APIKey struct {
type LoginType string
const (
LoginTypeUnknown LoginType = ""
LoginTypePassword LoginType = "password"
LoginTypeGithub LoginType = "github"
LoginTypeOIDC LoginType = "oidc"

View File

@ -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"`
}

2
docs/api/audit.md generated
View File

@ -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": [
{

View File

@ -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"
}
```

23
docs/api/enterprise.md generated
View File

@ -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` |

41
docs/api/schemas.md generated
View File

@ -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": ""
}
```

21
docs/api/users.md generated
View File

@ -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": [
{

View File

@ -10,14 +10,6 @@ coder users create [flags]
## Options
### --disable-login
| | |
| ---- | ----------------- |
| Type | <code>bool</code> |
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 | <code>string</code> |
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
| | |

View File

@ -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

View File

@ -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",

View File

@ -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 (
<MenuItem key="value" id={"item-" + value} value={value}>
{title}
{/* TODO: Add description */}
</MenuItem>
)
}
export const CreateUserForm: FC<
React.PropsWithChildren<CreateUserFormProps>
> = ({ onSubmit, onCancel, error, isLoading, myOrgId }) => {
> = ({ onSubmit, onCancel, error, isLoading, myOrgId, authMethods }) => {
const form: FormikContextType<TypesGen.CreateUserRequest> =
useFormik<TypesGen.CreateUserRequest>({
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 (
<FullPageForm title="Create user">
{isApiError(error) && !hasApiFieldErrors(error) && (
@ -85,13 +142,39 @@ export const CreateUserForm: FC<
label={Language.emailLabel}
/>
<TextField
{...getFieldHelpers("password")}
{...getFieldHelpers(
"password",
form.values.login_type === "password"
? ""
: "No password required for this login type",
)}
autoComplete="current-password"
fullWidth
id="password"
data-testid="password-input"
disabled={form.values.login_type !== "password"}
label={Language.passwordLabel}
type="password"
/>
<TextField
{...getFieldHelpers(
"login_type",
"Authentication method for this user",
)}
select
id="login_type"
data-testid="login-type-input"
value={form.values.login_type}
label="Login Type"
onChange={async (e) => {
if (e.target.value !== "password") {
await form.setFieldValue("password", "")
}
await form.setFieldValue("login_type", e.target.value)
}}
>
{methods}
</TextField>
</Stack>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
</form>

View File

@ -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,
)

View File

@ -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 (
<Margins>
<Helmet>
@ -33,6 +42,7 @@ export const CreateUserPage: FC = () => {
<CreateUserForm
error={error}
authMethods={authMethods}
onSubmit={(user: TypesGen.CreateUserRequest) =>
createUserSend({ type: "CREATE", user })
}