Skip to content

feat: add login type 'none' to prevent password login #8009

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cli/testdata/coder_users_create_--help.golden
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
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.

Expand Down
23 changes: 18 additions & 5 deletions cli/usercreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import (

func (r *RootCmd) userCreate() *clibase.Cmd {
var (
email string
username string
password string
email string
username string
password string
disableLogin bool
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Expand Down Expand Up @@ -53,7 +54,7 @@ func (r *RootCmd) userCreate() *clibase.Cmd {
return err
}
}
if password == "" {
if password == "" && !disableLogin {
password, err = cryptorand.StringCharset(cryptorand.Human, 20)
if err != nil {
return err
Expand All @@ -65,10 +66,16 @@ func (r *RootCmd) userCreate() *clibase.Cmd {
Username: username,
Password: password,
OrganizationID: organization.ID,
DisableLogin: disableLogin,
})
if err != nil {
return err
}
authenticationMethod := `Your password is: ` + cliui.DefaultStyles.Field.Render(password)
if disableLogin {
authenticationMethod = "Login has been disabled for this user. Contact your administrator to authenticate."
}

_, _ = fmt.Fprintln(inv.Stderr, `A new user has been created!
Share the instructions below to get them started.
`+cliui.DefaultStyles.Placeholder.Render("—————————————————————————————————————————————————")+`
Expand All @@ -78,7 +85,7 @@ https://github.com/coder/coder/releases
Run `+cliui.DefaultStyles.Code.Render("coder login "+client.URL.String())+` to authenticate.

Your email is: `+cliui.DefaultStyles.Field.Render(email)+`
Your password is: `+cliui.DefaultStyles.Field.Render(password)+`
`+authenticationMethod+`

Create a workspace `+cliui.DefaultStyles.Code.Render("coder create")+`!`)
return nil
Expand All @@ -103,6 +110,12 @@ Create a workspace `+cliui.DefaultStyles.Code.Render("coder create")+`!`)
Description: "Specifies a password for the new user.",
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. " +
"Be careful when using this flag as it can lock the user out of their account.",
Value: clibase.BoolOf(&disableLogin),
},
}
return cmd
}
11 changes: 8 additions & 3 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 8 additions & 3 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 30 additions & 9 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,36 +497,57 @@ func CreateFirstUser(t testing.TB, client *codersdk.Client) codersdk.CreateFirst

// CreateAnotherUser creates and authenticates a new user.
func CreateAnotherUser(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, roles ...string) (*codersdk.Client, codersdk.User) {
return createAnotherUserRetry(t, client, organizationID, 5, roles...)
return createAnotherUserRetry(t, client, organizationID, 5, roles)
}

func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, retries int, roles ...string) (*codersdk.Client, codersdk.User) {
func CreateAnotherUserMutators(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, roles []string, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) {
return createAnotherUserRetry(t, client, organizationID, 5, roles, mutators...)
}

func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, retries int, roles []string, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) {
req := codersdk.CreateUserRequest{
Email: namesgenerator.GetRandomName(10) + "@coder.com",
Username: randomUsername(t),
Password: "SomeSecurePassword!",
OrganizationID: organizationID,
}
for _, m := range mutators {
m(&req)
}

user, err := client.CreateUser(context.Background(), req)
var apiError *codersdk.Error
// If the user already exists by username or email conflict, try again up to "retries" times.
if err != nil && retries >= 0 && xerrors.As(err, &apiError) {
if apiError.StatusCode() == http.StatusConflict {
retries--
return createAnotherUserRetry(t, client, organizationID, retries, roles...)
return createAnotherUserRetry(t, client, organizationID, retries, roles)
}
}
require.NoError(t, err)

login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: req.Email,
Password: req.Password,
})
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 {
// 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{
Lifetime: time.Hour * 24,
Scope: codersdk.APIKeyScopeAll,
TokenName: "no-password-user-token",
})
require.NoError(t, err)
sessionToken = token.Key
}

other := codersdk.New(client.URL)
other.SetSessionToken(login.SessionToken)
other.SetSessionToken(sessionToken)
t.Cleanup(func() {
other.HTTPClient.CloseIdleConnections()
})
Expand Down
5 changes: 4 additions & 1 deletion coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coderd/database/migrations/000126_login_type_none.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- It's not possible to drop enum values from enum types, so the UP has "IF NOT
-- EXISTS".
3 changes: 3 additions & 0 deletions coderd/database/migrations/000126_login_type_none.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TYPE login_type ADD VALUE IF NOT EXISTS 'none';

COMMENT ON TYPE login_type IS 'Specifies the method of authentication. "none" is a special case in which no authentication method is allowed.';
6 changes: 5 additions & 1 deletion coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions coderd/userauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ func TestUserLogin(t *testing.T) {
require.ErrorAs(t, err, &apiErr)
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.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.DisableLogin = true
})

_, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: anotherUser.Email,
Password: "SomeSecurePassword!",
})
require.Error(t, err)
})
}

func TestUserAuthMethods(t *testing.T) {
Expand Down
29 changes: 21 additions & 8 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,21 +351,34 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
}
}

err = userpassword.Validate(req.Password)
if err != nil {
if req.DisableLogin && req.Password != "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Password not strong enough!",
Validations: []codersdk.ValidationError{{
Field: "password",
Detail: err.Error(),
}},
Message: "Cannot set password when disabling login.",
})
return
}

var loginType database.LoginType
if req.DisableLogin {
loginType = database.LoginTypeNone
} else {
err = userpassword.Validate(req.Password)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Password not strong enough!",
Validations: []codersdk.ValidationError{{
Field: "password",
Detail: err.Error(),
}},
})
return
}
loginType = database.LoginTypePassword
}

user, _, err := api.CreateUser(ctx, api.Database, CreateUserRequest{
CreateUserRequest: req,
LoginType: database.LoginTypePassword,
LoginType: loginType,
})
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Expand Down
5 changes: 5 additions & 0 deletions codersdk/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ const (
LoginTypeGithub LoginType = "github"
LoginTypeOIDC LoginType = "oidc"
LoginTypeToken LoginType = "token"
// LoginTypeNone is used if no login method is available for this user.
// If this is set, the user has no method of logging in.
// API keys can still be created by an owner and used by the user.
// These keys would use the `LoginTypeToken` type.
LoginTypeNone LoginType = "none"
)

type APIKeyScope string
Expand Down
9 changes: 6 additions & 3 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,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"`
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"`
// 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.
DisableLogin bool `json:"disable_login"`
OrganizationID uuid.UUID `json:"organization_id" validate:"" format:"uuid"`
}

Expand Down
15 changes: 9 additions & 6 deletions docs/api/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -1517,6 +1517,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in

```json
{
"disable_login": true,
"email": "user@example.com",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"password": "string",
Expand All @@ -1526,12 +1527,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in

### Properties

| Name | Type | Required | Restrictions | Description |
| ----------------- | ------ | -------- | ------------ | ----------- |
| `email` | string | true | | |
| `organization_id` | string | false | | |
| `password` | string | true | | |
| `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. |
| `email` | string | true | | |
| `organization_id` | string | false | | |
| `password` | string | false | | |
| `username` | string | true | | |

## codersdk.CreateWorkspaceBuildRequest

Expand Down Expand Up @@ -2825,6 +2827,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `github` |
| `oidc` |
| `token` |
| `none` |

## codersdk.LoginWithPasswordRequest

Expand Down
1 change: 1 addition & 0 deletions docs/api/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ curl -X POST http://coder-server:8080/api/v2/users \

```json
{
"disable_login": true,
"email": "user@example.com",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"password": "string",
Expand Down
Loading