mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
Usage: coder users create [flags]
|
||||
|
||||
[1mOptions[0m
|
||||
--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.
|
||||
|
||||
|
||||
+18
-5
@@ -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{
|
||||
@@ -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
|
||||
@@ -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("—————————————————————————————————————————————————")+`
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
+8
-3
@@ -6859,10 +6859,13 @@ const docTemplate = `{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email",
|
||||
"password",
|
||||
"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.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
@@ -7621,13 +7624,15 @@ const docTemplate = `{
|
||||
"password",
|
||||
"github",
|
||||
"oidc",
|
||||
"token"
|
||||
"token",
|
||||
"none"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"LoginTypePassword",
|
||||
"LoginTypeGithub",
|
||||
"LoginTypeOIDC",
|
||||
"LoginTypeToken"
|
||||
"LoginTypeToken",
|
||||
"LoginTypeNone"
|
||||
]
|
||||
},
|
||||
"codersdk.LoginWithPasswordRequest": {
|
||||
|
||||
Generated
+8
-3
@@ -6107,8 +6107,12 @@
|
||||
},
|
||||
"codersdk.CreateUserRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "password", "username"],
|
||||
"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.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
@@ -6818,12 +6822,13 @@
|
||||
},
|
||||
"codersdk.LoginType": {
|
||||
"type": "string",
|
||||
"enum": ["password", "github", "oidc", "token"],
|
||||
"enum": ["password", "github", "oidc", "token", "none"],
|
||||
"x-enum-varnames": [
|
||||
"LoginTypePassword",
|
||||
"LoginTypeGithub",
|
||||
"LoginTypeOIDC",
|
||||
"LoginTypeToken"
|
||||
"LoginTypeToken",
|
||||
"LoginTypeNone"
|
||||
]
|
||||
},
|
||||
"codersdk.LoginWithPasswordRequest": {
|
||||
|
||||
@@ -503,16 +503,23 @@ 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
|
||||
@@ -520,19 +527,33 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI
|
||||
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()
|
||||
})
|
||||
|
||||
Generated
+4
-1
@@ -45,9 +45,12 @@ CREATE TYPE login_type AS ENUM (
|
||||
'password',
|
||||
'github',
|
||||
'oidc',
|
||||
'token'
|
||||
'token',
|
||||
'none'
|
||||
);
|
||||
|
||||
COMMENT ON TYPE login_type IS 'Specifies the method of authentication. "none" is a special case in which no authentication method is allowed.';
|
||||
|
||||
CREATE TYPE parameter_destination_scheme AS ENUM (
|
||||
'none',
|
||||
'environment_variable',
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- It's not possible to drop enum values from enum types, so the UP has "IF NOT
|
||||
-- EXISTS".
|
||||
@@ -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.';
|
||||
@@ -397,6 +397,7 @@ func AllLogSourceValues() []LogSource {
|
||||
}
|
||||
}
|
||||
|
||||
// Specifies the method of authentication. "none" is a special case in which no authentication method is allowed.
|
||||
type LoginType string
|
||||
|
||||
const (
|
||||
@@ -404,6 +405,7 @@ const (
|
||||
LoginTypeGithub LoginType = "github"
|
||||
LoginTypeOIDC LoginType = "oidc"
|
||||
LoginTypeToken LoginType = "token"
|
||||
LoginTypeNone LoginType = "none"
|
||||
)
|
||||
|
||||
func (e *LoginType) Scan(src interface{}) error {
|
||||
@@ -446,7 +448,8 @@ func (e LoginType) Valid() bool {
|
||||
case LoginTypePassword,
|
||||
LoginTypeGithub,
|
||||
LoginTypeOIDC,
|
||||
LoginTypeToken:
|
||||
LoginTypeToken,
|
||||
LoginTypeNone:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -458,6 +461,7 @@ func AllLoginTypeValues() []LoginType {
|
||||
LoginTypeGithub,
|
||||
LoginTypeOIDC,
|
||||
LoginTypeToken,
|
||||
LoginTypeNone,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+21
-8
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-3
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -1519,6 +1519,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",
|
||||
@@ -1528,12 +1529,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
|
||||
|
||||
@@ -2827,6 +2829,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
| `github` |
|
||||
| `oidc` |
|
||||
| `token` |
|
||||
| `none` |
|
||||
|
||||
## codersdk.LoginWithPasswordRequest
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,6 +10,14 @@ 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
|
||||
|
||||
| | |
|
||||
|
||||
@@ -224,6 +224,7 @@ export interface CreateUserRequest {
|
||||
readonly email: string
|
||||
readonly username: string
|
||||
readonly password: string
|
||||
readonly disable_login: boolean
|
||||
readonly organization_id: string
|
||||
}
|
||||
|
||||
@@ -1396,8 +1397,14 @@ export type LogSource = "provisioner" | "provisioner_daemon"
|
||||
export const LogSources: LogSource[] = ["provisioner", "provisioner_daemon"]
|
||||
|
||||
// From codersdk/apikey.go
|
||||
export type LoginType = "github" | "oidc" | "password" | "token"
|
||||
export const LoginTypes: LoginType[] = ["github", "oidc", "password", "token"]
|
||||
export type LoginType = "github" | "none" | "oidc" | "password" | "token"
|
||||
export const LoginTypes: LoginType[] = [
|
||||
"github",
|
||||
"none",
|
||||
"oidc",
|
||||
"password",
|
||||
"token",
|
||||
]
|
||||
|
||||
// From codersdk/provisionerdaemons.go
|
||||
export type ProvisionerJobStatus =
|
||||
|
||||
@@ -50,6 +50,7 @@ export const CreateUserForm: FC<
|
||||
password: "",
|
||||
username: "",
|
||||
organization_id: myOrgId,
|
||||
disable_login: false,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
|
||||
Reference in New Issue
Block a user