Skip to content
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
35 changes: 24 additions & 11 deletions gen/go/authorizer/v1/admin.pb.go

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

3 changes: 3 additions & 0 deletions gen/openapi/authorizer.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -2072,6 +2072,9 @@
"items": {
"type": "string"
}
},
"is_multi_factor_auth_service_enabled": {
"type": "boolean"
}
},
"description": "AdminMeta mirrors model.AdminMeta exactly (no version field)."
Expand Down
70 changes: 67 additions & 3 deletions internal/graph/generated/generated.go

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

7 changes: 4 additions & 3 deletions internal/graph/model/models_gen.go

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

5 changes: 5 additions & 0 deletions internal/graph/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ type AdminMeta {
roles: [String!]!
default_roles: [String!]!
protected_roles: [String!]!
# is_multi_factor_auth_service_enabled reports whether MFA can actually be used
# on this instance (at least one usable method: TOTP, or email/SMS OTP with its
# provider configured). The dashboard uses it to gate the per-user MFA enable
# control.
is_multi_factor_auth_service_enabled: Boolean!
}

type User {
Expand Down
7 changes: 4 additions & 3 deletions internal/grpcsrv/handlers/admin_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ func projectAdminMeta(m *model.AdminMeta) *authorizerv1.AdminMeta {
return nil
}
return &authorizerv1.AdminMeta{
Roles: m.Roles,
DefaultRoles: m.DefaultRoles,
ProtectedRoles: m.ProtectedRoles,
Roles: m.Roles,
DefaultRoles: m.DefaultRoles,
ProtectedRoles: m.ProtectedRoles,
IsMultiFactorAuthServiceEnabled: m.IsMultiFactorAuthServiceEnabled,
}
}

Expand Down
82 changes: 82 additions & 0 deletions internal/integration_tests/mfa_service_availability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package integration_tests

import (
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"

"github.com/authorizerdev/authorizer/internal/graph/model"
"github.com/authorizerdev/authorizer/internal/refs"
)

// TestMFAServiceAvailability verifies that enabling a user's MFA via the admin
// UpdateUser path is gated on the instance actually being able to do MFA (the
// same criteria login uses), that _admin_meta reports that availability, and
// that disabling MFA is always allowed regardless.
func TestMFAServiceAvailability(t *testing.T) {
t.Run("service disabled: admin_meta false, enable rejected, disable allowed", func(t *testing.T) {
cfg := getTestConfig() // no MFA flags -> service unavailable
ts := initTestSetup(t, cfg)
_, ctx := createContext(ts)

setAdminCookie(t, ts)
meta, err := ts.GraphQLProvider.AdminMeta(ctx)
require.NoError(t, err)
require.False(t, meta.IsMultiFactorAuthServiceEnabled, "no MFA method configured")

email := "mfa_off_" + uuid.New().String() + "@authorizer.dev"
su, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{
Email: &email, Password: "Password@123", ConfirmPassword: "Password@123",
})
require.NoError(t, err)

// Enabling MFA is rejected when no MFA service is available.
setAdminCookie(t, ts)
_, err = ts.GraphQLProvider.UpdateUser(ctx, &model.UpdateUserRequest{
ID: su.User.ID, IsMultiFactorAuthEnabled: refs.NewBoolRef(true),
})
require.Error(t, err)

// Force the user MFA-enabled directly, then confirm disabling is allowed
// even though the service is off (an admin must be able to turn it off).
u, err := ts.StorageProvider.GetUserByID(ctx, su.User.ID)
require.NoError(t, err)
u.IsMultiFactorAuthEnabled = refs.NewBoolRef(true)
_, err = ts.StorageProvider.UpdateUser(ctx, u)
require.NoError(t, err)

setAdminCookie(t, ts)
res, err := ts.GraphQLProvider.UpdateUser(ctx, &model.UpdateUserRequest{
ID: su.User.ID, IsMultiFactorAuthEnabled: refs.NewBoolRef(false),
})
require.NoError(t, err)
require.False(t, refs.BoolValue(res.IsMultiFactorAuthEnabled))
})

t.Run("service enabled (mfa+totp): admin_meta true, enable succeeds", func(t *testing.T) {
cfg := getTestConfig()
cfg.EnableMFA = true
cfg.EnableTOTPLogin = true
ts := initTestSetup(t, cfg)
_, ctx := createContext(ts)

setAdminCookie(t, ts)
meta, err := ts.GraphQLProvider.AdminMeta(ctx)
require.NoError(t, err)
require.True(t, meta.IsMultiFactorAuthServiceEnabled)

email := "mfa_on_" + uuid.New().String() + "@authorizer.dev"
su, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{
Email: &email, Password: "Password@123", ConfirmPassword: "Password@123",
})
require.NoError(t, err)

setAdminCookie(t, ts)
res, err := ts.GraphQLProvider.UpdateUser(ctx, &model.UpdateUserRequest{
ID: su.User.ID, IsMultiFactorAuthEnabled: refs.NewBoolRef(true),
})
require.NoError(t, err)
require.True(t, refs.BoolValue(res.IsMultiFactorAuthEnabled))
})
}
7 changes: 4 additions & 3 deletions internal/service/admin_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ func (p *provider) AdminMeta(ctx context.Context, meta RequestMetadata) (*model.
protectedRoles = []string{}
}
return &model.AdminMeta{
Roles: roles,
DefaultRoles: defaultRoles,
ProtectedRoles: protectedRoles,
Roles: roles,
DefaultRoles: defaultRoles,
ProtectedRoles: protectedRoles,
IsMultiFactorAuthServiceEnabled: p.isMFAServiceAvailable(),
}, nil, nil
}
31 changes: 20 additions & 11 deletions internal/service/admin_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ import (
"github.com/authorizerdev/authorizer/internal/validators"
)

// isMFAServiceAvailable reports whether multi-factor auth can actually be used
// on this instance. It mirrors the login-time gating exactly (see login.go): at
// least one method must be usable — email OTP needs SMTP, SMS OTP needs Twilio,
// TOTP needs nothing extra. This is precisely config.EnableMFA, which is derived
// (config.Finalize) as the OR of those usable methods. The admin UpdateUser and
// self-service update_profile MFA-enable paths use this so they never accept an
// MFA state that login would be unable to honor.
func (p *provider) isMFAServiceAvailable() bool {
c := p.Config
return c.EnableMFA && ((c.EnableEmailOTP && c.IsEmailServiceEnabled) ||
(c.EnableSMSOTP && c.IsSMSServiceEnabled) ||
c.EnableTOTPLogin)
}

// Users returns a paginated list of all users, optionally filtered by a
// case-insensitive substring search (params.Query) over email/given_name/
// family_name/nickname. Requires super-admin auth. Logic migrated from
Expand Down Expand Up @@ -160,18 +174,13 @@ func (p *provider) UpdateUser(ctx context.Context, meta RequestMetadata, params
}

if params.IsMultiFactorAuthEnabled != nil && refs.BoolValue(user.IsMultiFactorAuthEnabled) != refs.BoolValue(params.IsMultiFactorAuthEnabled) {
user.IsMultiFactorAuthEnabled = params.IsMultiFactorAuthEnabled
if refs.BoolValue(params.IsMultiFactorAuthEnabled) {
// Check if totp, email or sms is enabled.
isMailOTPEnvServiceEnabled := p.Config.EnableEmailOTP
isTOTPEnvServiceEnabled := p.Config.EnableTOTPLogin
isSMSOTPEnvServiceEnabled := p.Config.EnableSMSOTP
// Initialize a flag to check if enabling Mail OTP is required.
if !isMailOTPEnvServiceEnabled && !isTOTPEnvServiceEnabled && !isSMSOTPEnvServiceEnabled {
log.Debug().Msg("cannot enable multi factor authentication as all mfa services are disabled")
return nil, nil, errors.New("cannot enable multi factor authentication as all mfa services are disabled")
}
// Only gate the enable action; disabling MFA is always allowed so an admin
// can still turn it off after the server has stopped offering any method.
if refs.BoolValue(params.IsMultiFactorAuthEnabled) && !p.isMFAServiceAvailable() {
log.Debug().Msg("cannot enable multi factor authentication as no mfa method is available")
return nil, nil, errors.New("cannot enable MFA: no MFA method is available on this server — ensure TOTP is enabled (do not set --disable-totp-login) or configure an email (SMTP) or SMS (Twilio) provider for OTP")
}
user.IsMultiFactorAuthEnabled = params.IsMultiFactorAuthEnabled
}

if params.EmailVerified != nil {
Expand Down
13 changes: 5 additions & 8 deletions internal/service/update_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,11 @@ func (p *provider) UpdateProfile(ctx context.Context, meta RequestMetadata, para
}
// Check if the user is trying to enable or disable multi-factor authentication (MFA)
if params.IsMultiFactorAuthEnabled != nil && refs.BoolValue(user.IsMultiFactorAuthEnabled) != refs.BoolValue(params.IsMultiFactorAuthEnabled) {
// Check if totp, email or sms is enabled
isMailOTPEnvServiceEnabled := p.Config.EnableEmailOTP
isTOTPEnvServiceEnabled := p.Config.EnableTOTPLogin
isSMSOTPEnvServiceEnabled := p.Config.EnableSMSOTP
// Initialize a flag to check if enabling Mail OTP is required
if !isMailOTPEnvServiceEnabled && !isTOTPEnvServiceEnabled && !isSMSOTPEnvServiceEnabled {
log.Debug().Msg("Cannot enable mfa service as all mfa services are disabled")
return nil, nil, FailedPrecondition("cannot enable multi factor authentication as all mfa services are disabled")
// Only gate the enable action; disabling is always allowed (subject to the
// enforce check below). Uses the same availability rule as login-time gating.
if refs.BoolValue(params.IsMultiFactorAuthEnabled) && !p.isMFAServiceAvailable() {
log.Debug().Msg("Cannot enable mfa as no mfa method is available")
return nil, nil, FailedPrecondition("cannot enable MFA: no MFA method is available on this server — ensure TOTP is enabled (do not set --disable-totp-login) or configure an email (SMTP) or SMS (Twilio) provider for OTP")
}

isMFAEnforced := p.Config.EnforceMFA
Expand Down
1 change: 1 addition & 0 deletions proto/authorizer/v1/admin.proto
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ message AdminMeta {
repeated string roles = 1;
repeated string default_roles = 2;
repeated string protected_roles = 3;
bool is_multi_factor_auth_service_enabled = 4;
}

// === Users ===
Expand Down
1 change: 1 addition & 0 deletions web/dashboard/src/graphql/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export const AdminRolesQuery = `
query adminMeta {
_admin_meta {
roles
is_multi_factor_auth_service_enabled
}
}
`;
Expand Down
Loading
Loading