Skip to content

Commit 6c4c3d6

Browse files
authored
feat: add login type 'none' to prevent password login (#8009)
* feat: add login type 'none' to prevent login Users with this login type must use tokens to authenticate. Tokens must come from some other source, not a /login with password authentication
1 parent cbd49ab commit 6c4c3d6

18 files changed

+160
-41
lines changed

cli/testdata/coder_users_create_--help.golden

+6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
Usage: coder users create [flags]
22

33
Options
4+
--disable-login bool
5+
Disabling login for a user prevents the user from authenticating via
6+
password or IdP login. Authentication requires an API key/token
7+
generated by an admin. Be careful when using this flag as it can lock
8+
the user out of their account.
9+
410
-e, --email string
511
Specifies an email address for the new user.
612

cli/usercreate.go

+18-5
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import (
1414

1515
func (r *RootCmd) userCreate() *clibase.Cmd {
1616
var (
17-
email string
18-
username string
19-
password string
17+
email string
18+
username string
19+
password string
20+
disableLogin bool
2021
)
2122
client := new(codersdk.Client)
2223
cmd := &clibase.Cmd{
@@ -53,7 +54,7 @@ func (r *RootCmd) userCreate() *clibase.Cmd {
5354
return err
5455
}
5556
}
56-
if password == "" {
57+
if password == "" && !disableLogin {
5758
password, err = cryptorand.StringCharset(cryptorand.Human, 20)
5859
if err != nil {
5960
return err
@@ -65,10 +66,16 @@ func (r *RootCmd) userCreate() *clibase.Cmd {
6566
Username: username,
6667
Password: password,
6768
OrganizationID: organization.ID,
69+
DisableLogin: disableLogin,
6870
})
6971
if err != nil {
7072
return err
7173
}
74+
authenticationMethod := `Your password is: ` + cliui.DefaultStyles.Field.Render(password)
75+
if disableLogin {
76+
authenticationMethod = "Login has been disabled for this user. Contact your administrator to authenticate."
77+
}
78+
7279
_, _ = fmt.Fprintln(inv.Stderr, `A new user has been created!
7380
Share the instructions below to get them started.
7481
`+cliui.DefaultStyles.Placeholder.Render("—————————————————————————————————————————————————")+`
@@ -78,7 +85,7 @@ https://github.com/coder/coder/releases
7885
Run `+cliui.DefaultStyles.Code.Render("coder login "+client.URL.String())+` to authenticate.
7986
8087
Your email is: `+cliui.DefaultStyles.Field.Render(email)+`
81-
Your password is: `+cliui.DefaultStyles.Field.Render(password)+`
88+
`+authenticationMethod+`
8289
8390
Create a workspace `+cliui.DefaultStyles.Code.Render("coder create")+`!`)
8491
return nil
@@ -103,6 +110,12 @@ Create a workspace `+cliui.DefaultStyles.Code.Render("coder create")+`!`)
103110
Description: "Specifies a password for the new user.",
104111
Value: clibase.StringOf(&password),
105112
},
113+
{
114+
Flag: "disable-login",
115+
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. " +
116+
"Be careful when using this flag as it can lock the user out of their account.",
117+
Value: clibase.BoolOf(&disableLogin),
118+
},
106119
}
107120
return cmd
108121
}

coderd/apidoc/docs.go

+8-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+8-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderdtest/coderdtest.go

+30-9
Original file line numberDiff line numberDiff line change
@@ -503,36 +503,57 @@ func CreateFirstUser(t testing.TB, client *codersdk.Client) codersdk.CreateFirst
503503

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

509-
func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, retries int, roles ...string) (*codersdk.Client, codersdk.User) {
509+
func CreateAnotherUserMutators(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, roles []string, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) {
510+
return createAnotherUserRetry(t, client, organizationID, 5, roles, mutators...)
511+
}
512+
513+
func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, retries int, roles []string, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) {
510514
req := codersdk.CreateUserRequest{
511515
Email: namesgenerator.GetRandomName(10) + "@coder.com",
512516
Username: randomUsername(t),
513517
Password: "SomeSecurePassword!",
514518
OrganizationID: organizationID,
515519
}
520+
for _, m := range mutators {
521+
m(&req)
522+
}
516523

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

528-
login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
529-
Email: req.Email,
530-
Password: req.Password,
531-
})
532-
require.NoError(t, err)
535+
var sessionToken string
536+
if !req.DisableLogin {
537+
login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
538+
Email: req.Email,
539+
Password: req.Password,
540+
})
541+
require.NoError(t, err)
542+
sessionToken = login.SessionToken
543+
} else {
544+
// Cannot log in with a disabled login user. So make it an api key from
545+
// the client making this user.
546+
token, err := client.CreateToken(context.Background(), user.ID.String(), codersdk.CreateTokenRequest{
547+
Lifetime: time.Hour * 24,
548+
Scope: codersdk.APIKeyScopeAll,
549+
TokenName: "no-password-user-token",
550+
})
551+
require.NoError(t, err)
552+
sessionToken = token.Key
553+
}
533554

534555
other := codersdk.New(client.URL)
535-
other.SetSessionToken(login.SessionToken)
556+
other.SetSessionToken(sessionToken)
536557
t.Cleanup(func() {
537558
other.HTTPClient.CloseIdleConnections()
538559
})

coderd/database/dump.sql

+4-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- It's not possible to drop enum values from enum types, so the UP has "IF NOT
2+
-- EXISTS".
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TYPE login_type ADD VALUE IF NOT EXISTS 'none';
2+
3+
COMMENT ON TYPE login_type IS 'Specifies the method of authentication. "none" is a special case in which no authentication method is allowed.';

coderd/database/models.go

+5-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/userauth_test.go

+16
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,22 @@ func TestUserLogin(t *testing.T) {
5656
require.ErrorAs(t, err, &apiErr)
5757
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
5858
})
59+
// Password auth should fail if the user is made without password login.
60+
t.Run("LoginTypeNone", func(t *testing.T) {
61+
t.Parallel()
62+
client := coderdtest.New(t, nil)
63+
user := coderdtest.CreateFirstUser(t, client)
64+
anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequest) {
65+
r.Password = ""
66+
r.DisableLogin = true
67+
})
68+
69+
_, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
70+
Email: anotherUser.Email,
71+
Password: "SomeSecurePassword!",
72+
})
73+
require.Error(t, err)
74+
})
5975
}
6076

6177
func TestUserAuthMethods(t *testing.T) {

coderd/users.go

+21-8
Original file line numberDiff line numberDiff line change
@@ -351,21 +351,34 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
351351
}
352352
}
353353

354-
err = userpassword.Validate(req.Password)
355-
if err != nil {
354+
if req.DisableLogin && req.Password != "" {
356355
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
357-
Message: "Password not strong enough!",
358-
Validations: []codersdk.ValidationError{{
359-
Field: "password",
360-
Detail: err.Error(),
361-
}},
356+
Message: "Cannot set password when disabling login.",
362357
})
363358
return
364359
}
365360

361+
var loginType database.LoginType
362+
if req.DisableLogin {
363+
loginType = database.LoginTypeNone
364+
} else {
365+
err = userpassword.Validate(req.Password)
366+
if err != nil {
367+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
368+
Message: "Password not strong enough!",
369+
Validations: []codersdk.ValidationError{{
370+
Field: "password",
371+
Detail: err.Error(),
372+
}},
373+
})
374+
return
375+
}
376+
loginType = database.LoginTypePassword
377+
}
378+
366379
user, _, err := api.CreateUser(ctx, api.Database, CreateUserRequest{
367380
CreateUserRequest: req,
368-
LoginType: database.LoginTypePassword,
381+
LoginType: loginType,
369382
})
370383
if dbauthz.IsNotAuthorizedError(err) {
371384
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{

codersdk/apikey.go

+5
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ const (
3232
LoginTypeGithub LoginType = "github"
3333
LoginTypeOIDC LoginType = "oidc"
3434
LoginTypeToken LoginType = "token"
35+
// LoginTypeNone is used if no login method is available for this user.
36+
// If this is set, the user has no method of logging in.
37+
// API keys can still be created by an owner and used by the user.
38+
// These keys would use the `LoginTypeToken` type.
39+
LoginTypeNone LoginType = "none"
3540
)
3641

3742
type APIKeyScope string

codersdk/users.go

+6-3
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,12 @@ type CreateFirstUserResponse struct {
6666
}
6767

6868
type CreateUserRequest struct {
69-
Email string `json:"email" validate:"required,email" format:"email"`
70-
Username string `json:"username" validate:"required,username"`
71-
Password string `json:"password" validate:"required"`
69+
Email string `json:"email" validate:"required,email" format:"email"`
70+
Username string `json:"username" validate:"required,username"`
71+
Password string `json:"password" validate:"required_if=DisableLogin false"`
72+
// DisableLogin sets the user's login type to 'none'. This prevents the user
73+
// from being able to use a password or any other authentication method to login.
74+
DisableLogin bool `json:"disable_login"`
7275
OrganizationID uuid.UUID `json:"organization_id" validate:"" format:"uuid"`
7376
}
7477

docs/api/schemas.md

+9-6
Original file line numberDiff line numberDiff line change
@@ -1519,6 +1519,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
15191519

15201520
```json
15211521
{
1522+
"disable_login": true,
15221523
"email": "user@example.com",
15231524
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
15241525
"password": "string",
@@ -1528,12 +1529,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
15281529

15291530
### Properties
15301531

1531-
| Name | Type | Required | Restrictions | Description |
1532-
| ----------------- | ------ | -------- | ------------ | ----------- |
1533-
| `email` | string | true | | |
1534-
| `organization_id` | string | false | | |
1535-
| `password` | string | true | | |
1536-
| `username` | string | true | | |
1532+
| Name | Type | Required | Restrictions | Description |
1533+
| ----------------- | ------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
1534+
| `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. |
1535+
| `email` | string | true | | |
1536+
| `organization_id` | string | false | | |
1537+
| `password` | string | false | | |
1538+
| `username` | string | true | | |
15371539

15381540
## codersdk.CreateWorkspaceBuildRequest
15391541

@@ -2827,6 +2829,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
28272829
| `github` |
28282830
| `oidc` |
28292831
| `token` |
2832+
| `none` |
28302833

28312834
## codersdk.LoginWithPasswordRequest
28322835

docs/api/users.md

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ curl -X POST http://coder-server:8080/api/v2/users \
7676
7777
```json
7878
{
79+
"disable_login": true,
7980
"email": "user@example.com",
8081
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
8182
"password": "string",

0 commit comments

Comments
 (0)