providers/radius: TOTP MFA support (#7217)
* move CheckPasswordMFA to flow executor Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add mfa support field to radius Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
3c734da86a
commit
8aafa06259
|
@ -21,6 +21,7 @@ class RadiusProviderSerializer(ProviderSerializer):
|
||||||
# an admin might have to view it
|
# an admin might have to view it
|
||||||
"shared_secret",
|
"shared_secret",
|
||||||
"outpost_set",
|
"outpost_set",
|
||||||
|
"mfa_support",
|
||||||
]
|
]
|
||||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
||||||
|
|
||||||
|
@ -55,6 +56,7 @@ class RadiusOutpostConfigSerializer(ModelSerializer):
|
||||||
"auth_flow_slug",
|
"auth_flow_slug",
|
||||||
"client_networks",
|
"client_networks",
|
||||||
"shared_secret",
|
"shared_secret",
|
||||||
|
"mfa_support",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Generated by Django 4.2.6 on 2023-10-18 15:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("authentik_providers_radius", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="radiusprovider",
|
||||||
|
name="mfa_support",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon.",
|
||||||
|
verbose_name="MFA Support",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -27,6 +27,17 @@ class RadiusProvider(OutpostModel, Provider):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mfa_support = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name="MFA Support",
|
||||||
|
help_text=_(
|
||||||
|
"When enabled, code-based multi-factor authentication can be used by appending a "
|
||||||
|
"semicolon and the TOTP code to the password. This should only be enabled if all "
|
||||||
|
"users that will bind to this provider have a TOTP device configured, as otherwise "
|
||||||
|
"a password may incorrectly be rejected if it contains a semicolon."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def launch_url(self) -> Optional[str]:
|
def launch_url(self) -> Optional[str]:
|
||||||
"""Radius never has a launch URL"""
|
"""Radius never has a launch URL"""
|
||||||
|
|
|
@ -4794,6 +4794,11 @@
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Shared secret",
|
"title": "Shared secret",
|
||||||
"description": "Shared secret between clients and server to hash packets."
|
"description": "Shared secret between clients and server to hash packets."
|
||||||
|
},
|
||||||
|
"mfa_support": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "MFA Support",
|
||||||
|
"description": "When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": []
|
"required": []
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
package flow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const CodePasswordSeparator = ";"
|
||||||
|
|
||||||
|
var alphaNum = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
|
||||||
|
|
||||||
|
// CheckPasswordInlineMFA For protocols that only support username/password, check if the password
|
||||||
|
// contains the TOTP code
|
||||||
|
func (fe *FlowExecutor) CheckPasswordInlineMFA() {
|
||||||
|
password := fe.Answers[StagePassword]
|
||||||
|
// We already have an authenticator answer
|
||||||
|
if fe.Answers[StageAuthenticatorValidate] != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// password doesn't contain the separator
|
||||||
|
if !strings.Contains(password, CodePasswordSeparator) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// password ends with the separator, so it won't contain an answer
|
||||||
|
if strings.HasSuffix(password, CodePasswordSeparator) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := strings.LastIndex(password, CodePasswordSeparator)
|
||||||
|
authenticator := password[idx+1:]
|
||||||
|
// Authenticator is either 6 chars (totp code) or 8 chars (long totp or static)
|
||||||
|
if len(authenticator) == 6 {
|
||||||
|
// authenticator answer isn't purely numerical, so won't be value
|
||||||
|
if _, err := strconv.Atoi(authenticator); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if len(authenticator) == 8 {
|
||||||
|
// 8 chars can be a long totp or static token, so it needs to be alphanumerical
|
||||||
|
if !alphaNum.MatchString(authenticator) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Any other length, doesn't contain an answer
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fe.Answers[StagePassword] = password[:idx]
|
||||||
|
fe.Answers[StageAuthenticatorValidate] = authenticator
|
||||||
|
}
|
|
@ -2,9 +2,6 @@ package direct
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"beryju.io/ldap"
|
"beryju.io/ldap"
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
|
@ -16,10 +13,6 @@ import (
|
||||||
"goauthentik.io/internal/outpost/ldap/metrics"
|
"goauthentik.io/internal/outpost/ldap/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
const CodePasswordSeparator = ";"
|
|
||||||
|
|
||||||
var alphaNum = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
|
|
||||||
|
|
||||||
func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResultCode, error) {
|
func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResultCode, error) {
|
||||||
fe := flow.NewFlowExecutor(req.Context(), db.si.GetAuthenticationFlowSlug(), db.si.GetAPIClient().GetConfig(), log.Fields{
|
fe := flow.NewFlowExecutor(req.Context(), db.si.GetAuthenticationFlowSlug(), db.si.GetAPIClient().GetConfig(), log.Fields{
|
||||||
"bindDN": req.BindDN,
|
"bindDN": req.BindDN,
|
||||||
|
@ -31,7 +24,9 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
|
||||||
|
|
||||||
fe.Answers[flow.StageIdentification] = username
|
fe.Answers[flow.StageIdentification] = username
|
||||||
fe.Answers[flow.StagePassword] = req.BindPW
|
fe.Answers[flow.StagePassword] = req.BindPW
|
||||||
db.CheckPasswordMFA(fe)
|
if db.si.GetMFASupport() {
|
||||||
|
fe.CheckPasswordInlineMFA()
|
||||||
|
}
|
||||||
|
|
||||||
passed, err := fe.Execute()
|
passed, err := fe.Execute()
|
||||||
flags := flags.UserFlags{
|
flags := flags.UserFlags{
|
||||||
|
@ -141,41 +136,3 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
|
||||||
uisp.Finish()
|
uisp.Finish()
|
||||||
return ldap.LDAPResultSuccess, nil
|
return ldap.LDAPResultSuccess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DirectBinder) CheckPasswordMFA(fe *flow.FlowExecutor) {
|
|
||||||
if !db.si.GetMFASupport() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
password := fe.Answers[flow.StagePassword]
|
|
||||||
// We already have an authenticator answer
|
|
||||||
if fe.Answers[flow.StageAuthenticatorValidate] != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// password doesn't contain the separator
|
|
||||||
if !strings.Contains(password, CodePasswordSeparator) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// password ends with the separator, so it won't contain an answer
|
|
||||||
if strings.HasSuffix(password, CodePasswordSeparator) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
idx := strings.LastIndex(password, CodePasswordSeparator)
|
|
||||||
authenticator := password[idx+1:]
|
|
||||||
// Authenticator is either 6 chars (totp code) or 8 chars (long totp or static)
|
|
||||||
if len(authenticator) == 6 {
|
|
||||||
// authenticator answer isn't purely numerical, so won't be value
|
|
||||||
if _, err := strconv.Atoi(authenticator); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else if len(authenticator) == 8 {
|
|
||||||
// 8 chars can be a long totp or static token, so it needs to be alphanumerical
|
|
||||||
if !alphaNum.MatchString(authenticator) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Any other length, doesn't contain an answer
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fe.Answers[flow.StagePassword] = password[:idx]
|
|
||||||
fe.Answers[flow.StageAuthenticatorValidate] = authenticator
|
|
||||||
}
|
|
||||||
|
|
|
@ -40,11 +40,10 @@ func (rs *RadiusServer) Refresh() error {
|
||||||
providers := make([]*ProviderInstance, len(outposts.Results))
|
providers := make([]*ProviderInstance, len(outposts.Results))
|
||||||
for idx, provider := range outposts.Results {
|
for idx, provider := range outposts.Results {
|
||||||
logger := log.WithField("logger", "authentik.outpost.radius").WithField("provider", provider.Name)
|
logger := log.WithField("logger", "authentik.outpost.radius").WithField("provider", provider.Name)
|
||||||
s := *provider.SharedSecret
|
|
||||||
c := *provider.ClientNetworks
|
|
||||||
providers[idx] = &ProviderInstance{
|
providers[idx] = &ProviderInstance{
|
||||||
SharedSecret: []byte(s),
|
SharedSecret: []byte(provider.GetSharedSecret()),
|
||||||
ClientNetworks: parseCIDRs(c),
|
ClientNetworks: parseCIDRs(provider.GetClientNetworks()),
|
||||||
|
MFASupport: provider.GetMfaSupport(),
|
||||||
appSlug: provider.ApplicationSlug,
|
appSlug: provider.ApplicationSlug,
|
||||||
flowSlug: provider.AuthFlowSlug,
|
flowSlug: provider.AuthFlowSlug,
|
||||||
s: rs,
|
s: rs,
|
||||||
|
|
|
@ -22,6 +22,9 @@ func (rs *RadiusServer) Handle_AccessRequest(w radius.ResponseWriter, r *RadiusR
|
||||||
|
|
||||||
fe.Answers[flow.StageIdentification] = username
|
fe.Answers[flow.StageIdentification] = username
|
||||||
fe.Answers[flow.StagePassword] = rfc2865.UserPassword_GetString(r.Packet)
|
fe.Answers[flow.StagePassword] = rfc2865.UserPassword_GetString(r.Packet)
|
||||||
|
if r.pi.MFASupport {
|
||||||
|
fe.CheckPasswordInlineMFA()
|
||||||
|
}
|
||||||
|
|
||||||
passed, err := fe.Execute()
|
passed, err := fe.Execute()
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ import (
|
||||||
type ProviderInstance struct {
|
type ProviderInstance struct {
|
||||||
ClientNetworks []*net.IPNet
|
ClientNetworks []*net.IPNet
|
||||||
SharedSecret []byte
|
SharedSecret []byte
|
||||||
|
MFASupport bool
|
||||||
|
|
||||||
appSlug string
|
appSlug string
|
||||||
flowSlug string
|
flowSlug string
|
||||||
|
|
28
schema.yml
28
schema.yml
|
@ -37484,6 +37484,13 @@ components:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
description: Shared secret between clients and server to hash packets.
|
description: Shared secret between clients and server to hash packets.
|
||||||
|
mfa_support:
|
||||||
|
type: boolean
|
||||||
|
description: When enabled, code-based multi-factor authentication can be
|
||||||
|
used by appending a semicolon and the TOTP code to the password. This
|
||||||
|
should only be enabled if all users that will bind to this provider have
|
||||||
|
a TOTP device configured, as otherwise a password may incorrectly be rejected
|
||||||
|
if it contains a semicolon.
|
||||||
PatchedReputationPolicyRequest:
|
PatchedReputationPolicyRequest:
|
||||||
type: object
|
type: object
|
||||||
description: Reputation Policy Serializer
|
description: Reputation Policy Serializer
|
||||||
|
@ -39379,6 +39386,13 @@ components:
|
||||||
shared_secret:
|
shared_secret:
|
||||||
type: string
|
type: string
|
||||||
description: Shared secret between clients and server to hash packets.
|
description: Shared secret between clients and server to hash packets.
|
||||||
|
mfa_support:
|
||||||
|
type: boolean
|
||||||
|
description: When enabled, code-based multi-factor authentication can be
|
||||||
|
used by appending a semicolon and the TOTP code to the password. This
|
||||||
|
should only be enabled if all users that will bind to this provider have
|
||||||
|
a TOTP device configured, as otherwise a password may incorrectly be rejected
|
||||||
|
if it contains a semicolon.
|
||||||
required:
|
required:
|
||||||
- application_slug
|
- application_slug
|
||||||
- auth_flow_slug
|
- auth_flow_slug
|
||||||
|
@ -39454,6 +39468,13 @@ components:
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
mfa_support:
|
||||||
|
type: boolean
|
||||||
|
description: When enabled, code-based multi-factor authentication can be
|
||||||
|
used by appending a semicolon and the TOTP code to the password. This
|
||||||
|
should only be enabled if all users that will bind to this provider have
|
||||||
|
a TOTP device configured, as otherwise a password may incorrectly be rejected
|
||||||
|
if it contains a semicolon.
|
||||||
required:
|
required:
|
||||||
- assigned_application_name
|
- assigned_application_name
|
||||||
- assigned_application_slug
|
- assigned_application_slug
|
||||||
|
@ -39499,6 +39520,13 @@ components:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
description: Shared secret between clients and server to hash packets.
|
description: Shared secret between clients and server to hash packets.
|
||||||
|
mfa_support:
|
||||||
|
type: boolean
|
||||||
|
description: When enabled, code-based multi-factor authentication can be
|
||||||
|
used by appending a semicolon and the TOTP code to the password. This
|
||||||
|
should only be enabled if all users that will bind to this provider have
|
||||||
|
a TOTP device configured, as otherwise a password may incorrectly be rejected
|
||||||
|
if it contains a semicolon.
|
||||||
required:
|
required:
|
||||||
- authorization_flow
|
- authorization_flow
|
||||||
- name
|
- name
|
||||||
|
|
|
@ -70,6 +70,26 @@ export class RadiusProviderFormPage extends ModelForm<RadiusProvider, number> {
|
||||||
></ak-tenanted-flow-search>
|
></ak-tenanted-flow-search>
|
||||||
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
|
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>
|
||||||
|
<ak-form-element-horizontal name="mfaSupport">
|
||||||
|
<label class="pf-c-switch">
|
||||||
|
<input
|
||||||
|
class="pf-c-switch__input"
|
||||||
|
type="checkbox"
|
||||||
|
?checked=${first(this.instance?.mfaSupport, true)}
|
||||||
|
/>
|
||||||
|
<span class="pf-c-switch__toggle">
|
||||||
|
<span class="pf-c-switch__toggle-icon">
|
||||||
|
<i class="fas fa-check" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="pf-c-switch__label">${msg("Code-based MFA Support")}</span>
|
||||||
|
</label>
|
||||||
|
<p class="pf-c-form__helper-text">
|
||||||
|
${msg(
|
||||||
|
"When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
|
||||||
<ak-form-group .expanded=${true}>
|
<ak-form-group .expanded=${true}>
|
||||||
<span slot="header"> ${msg("Protocol settings")} </span>
|
<span slot="header"> ${msg("Protocol settings")} </span>
|
||||||
|
|
Reference in New Issue