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:
Dean Sheather
2023-02-07 01:58:21 +11:00
committed by GitHub
parent 968d7e4dc5
commit 4fe221a700
21 changed files with 1352 additions and 542 deletions

3
coderd/apidoc/docs.go generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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