mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: add flag to disable password auth (#5991)
Adds a flag --disable-password-auth that prevents the password login endpoint from working unless the user has the "owner" (aka. site admin) role. Adds a subcommand `coder server create-admin-user` which creates a user directly in the database with the "owner" role, the "admin" role in every organization, and password auth. This is to avoid lock-out situations where all accounts have the login type set to an identity provider and nobody can login.
This commit is contained in:
3
coderd/apidoc/docs.go
generated
3
coderd/apidoc/docs.go
generated
@ -6012,6 +6012,9 @@ const docTemplate = `{
|
||||
"derp": {
|
||||
"$ref": "#/definitions/codersdk.DERP"
|
||||
},
|
||||
"disable_password_auth": {
|
||||
"$ref": "#/definitions/codersdk.DeploymentConfigField-bool"
|
||||
},
|
||||
"disable_path_apps": {
|
||||
"$ref": "#/definitions/codersdk.DeploymentConfigField-bool"
|
||||
},
|
||||
|
3
coderd/apidoc/swagger.json
generated
3
coderd/apidoc/swagger.json
generated
@ -5343,6 +5343,9 @@
|
||||
"derp": {
|
||||
"$ref": "#/definitions/codersdk.DERP"
|
||||
},
|
||||
"disable_password_auth": {
|
||||
"$ref": "#/definitions/codersdk.DeploymentConfigField-bool"
|
||||
},
|
||||
"disable_path_apps": {
|
||||
"$ref": "#/definitions/codersdk.DeploymentConfigField-bool"
|
||||
},
|
||||
|
@ -18,15 +18,15 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
var validate *validator.Validate
|
||||
var Validate *validator.Validate
|
||||
|
||||
// This init is used to create a validator and register validation-specific
|
||||
// functionality for the HTTP API.
|
||||
//
|
||||
// A single validator instance is used, because it caches struct parsing.
|
||||
func init() {
|
||||
validate = validator.New()
|
||||
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||
Validate = validator.New()
|
||||
Validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "-" {
|
||||
return ""
|
||||
@ -44,7 +44,7 @@ func init() {
|
||||
return valid == nil
|
||||
}
|
||||
for _, tag := range []string{"username", "template_name", "workspace_name"} {
|
||||
err := validate.RegisterValidation(tag, nameValidator)
|
||||
err := Validate.RegisterValidation(tag, nameValidator)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@ -59,7 +59,7 @@ func init() {
|
||||
valid := TemplateDisplayNameValid(str)
|
||||
return valid == nil
|
||||
}
|
||||
err := validate.RegisterValidation("template_display_name", templateDisplayNameValidator)
|
||||
err := Validate.RegisterValidation("template_display_name", templateDisplayNameValidator)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@ -144,7 +144,7 @@ func Read(ctx context.Context, rw http.ResponseWriter, r *http.Request, value in
|
||||
})
|
||||
return false
|
||||
}
|
||||
err = validate.Struct(value)
|
||||
err = Validate.Struct(value)
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) {
|
||||
apiErrors := make([]codersdk.ValidationError, 0, len(validationErrors))
|
||||
|
@ -62,8 +62,10 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.AuthMethods{
|
||||
Password: codersdk.AuthMethod{Enabled: true},
|
||||
Github: codersdk.AuthMethod{Enabled: api.GithubOAuth2Config != nil},
|
||||
Password: codersdk.AuthMethod{
|
||||
Enabled: !api.DeploymentConfig.DisablePasswordAuth.Value,
|
||||
},
|
||||
Github: codersdk.AuthMethod{Enabled: api.GithubOAuth2Config != nil},
|
||||
OIDC: codersdk.OIDCAuthMethod{
|
||||
AuthMethod: codersdk.AuthMethod{Enabled: api.OIDCConfig != nil},
|
||||
SignInText: signInText,
|
||||
|
@ -1028,6 +1028,24 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// If password authentication is disabled and the user does not have the
|
||||
// owner role, block the request.
|
||||
if api.DeploymentConfig.DisablePasswordAuth.Value {
|
||||
permitted := false
|
||||
for _, role := range user.RBACRoles {
|
||||
if role == rbac.RoleOwner() {
|
||||
permitted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !permitted {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Password authentication is disabled. Only administrators can sign in with password authentication.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if user.LoginType != database.LoginTypePassword {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: fmt.Sprintf("Incorrect login type, attempting to use %q but user is of login type %q", database.LoginTypePassword, user.LoginType),
|
||||
|
@ -86,35 +86,6 @@ func TestFirstUser(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
<-called
|
||||
})
|
||||
|
||||
t.Run("LastSeenAt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
firstUserResp := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
firstUser, err := client.User(ctx, firstUserResp.UserID.String())
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = coderdtest.CreateAnotherUser(t, client, firstUserResp.OrganizationID)
|
||||
|
||||
allUsersRes, err := client.Users(ctx, codersdk.UsersRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, allUsersRes.Users, 2)
|
||||
|
||||
// We sent the "GET Users" request with the first user, but the second user
|
||||
// should be Never since they haven't performed a request.
|
||||
for _, user := range allUsersRes.Users {
|
||||
if user.ID == firstUser.ID {
|
||||
require.WithinDuration(t, firstUser.LastSeenAt, database.Now(), testutil.WaitShort)
|
||||
} else {
|
||||
require.Zero(t, user.LastSeenAt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogin(t *testing.T) {
|
||||
@ -191,6 +162,56 @@ func TestPostLogin(t *testing.T) {
|
||||
require.Contains(t, apiErr.Message, "suspended")
|
||||
})
|
||||
|
||||
t.Run("DisabledPasswordAuth", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := coderdtest.DeploymentConfig(t)
|
||||
dc.DisablePasswordAuth.Value = true
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentConfig: dc,
|
||||
})
|
||||
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// With a user account.
|
||||
const password = "testpass"
|
||||
user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
Email: "test+user-@coder.com",
|
||||
Username: "user",
|
||||
Password: password,
|
||||
OrganizationID: first.OrganizationID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userClient := codersdk.New(client.URL)
|
||||
_, err = userClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: user.Email,
|
||||
Password: password,
|
||||
})
|
||||
require.Error(t, err)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||
require.Contains(t, apiErr.Message, "Password authentication is disabled")
|
||||
|
||||
// Promote the user account to an owner.
|
||||
_, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{
|
||||
Roles: []string{rbac.RoleOwner(), rbac.RoleMember()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Login with the user account.
|
||||
res, err := userClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: user.Email,
|
||||
Password: password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, res.SessionToken)
|
||||
})
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
@ -437,6 +458,35 @@ func TestPostUsers(t *testing.T) {
|
||||
require.Len(t, auditor.AuditLogs, 1)
|
||||
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[0].Action)
|
||||
})
|
||||
|
||||
t.Run("LastSeenAt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
firstUserResp := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
firstUser, err := client.User(ctx, firstUserResp.UserID.String())
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = coderdtest.CreateAnotherUser(t, client, firstUserResp.OrganizationID)
|
||||
|
||||
allUsersRes, err := client.Users(ctx, codersdk.UsersRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, allUsersRes.Users, 2)
|
||||
|
||||
// We sent the "GET Users" request with the first user, but the second user
|
||||
// should be Never since they haven't performed a request.
|
||||
for _, user := range allUsersRes.Users {
|
||||
if user.ID == firstUser.ID {
|
||||
require.WithinDuration(t, firstUser.LastSeenAt, database.Now(), testutil.WaitShort)
|
||||
} else {
|
||||
require.Zero(t, user.LastSeenAt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateUserProfile(t *testing.T) {
|
||||
|
Reference in New Issue
Block a user