mirror of
https://github.com/casdoor/casdoor.git
synced 2025-07-03 20:50:19 +08:00
feat: add TOTP multi-factor authentication (#2014)
* feat: add totp multi-factor authentication * feat: add license * feat:i18n and update yarn.lock * feat:i18n * fix: i18n
This commit is contained in:
@ -22,6 +22,8 @@ import (
|
||||
"github.com/beego/beego/context"
|
||||
)
|
||||
|
||||
const MfaRecoveryCodesSession = "mfa_recovery_codes"
|
||||
|
||||
type MfaSessionData struct {
|
||||
UserId string
|
||||
}
|
||||
@ -37,10 +39,10 @@ type MfaProps struct {
|
||||
}
|
||||
|
||||
type MfaInterface interface {
|
||||
SetupVerify(ctx *context.Context, passCode string) error
|
||||
Verify(passCode string) error
|
||||
Initiate(ctx *context.Context, name1 string, name2 string) (*MfaProps, error)
|
||||
Initiate(ctx *context.Context, userId string) (*MfaProps, error)
|
||||
SetupVerify(ctx *context.Context, passcode string) error
|
||||
Enable(ctx *context.Context, user *User) error
|
||||
Verify(passcode string) error
|
||||
}
|
||||
|
||||
const (
|
||||
@ -58,11 +60,11 @@ const (
|
||||
func GetMfaUtil(mfaType string, config *MfaProps) MfaInterface {
|
||||
switch mfaType {
|
||||
case SmsType:
|
||||
return NewSmsTwoFactor(config)
|
||||
return NewSmsMfaUtil(config)
|
||||
case EmailType:
|
||||
return NewEmailTwoFactor(config)
|
||||
return NewEmailMfaUtil(config)
|
||||
case TotpType:
|
||||
return nil
|
||||
return NewTotpMfaUtil(config)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -97,23 +99,9 @@ func MfaRecover(user *User, recoveryCode string) error {
|
||||
func GetAllMfaProps(user *User, masked bool) []*MfaProps {
|
||||
mfaProps := []*MfaProps{}
|
||||
|
||||
if user.MfaPhoneEnabled {
|
||||
mfaProps = append(mfaProps, user.GetMfaProps(SmsType, masked))
|
||||
} else {
|
||||
mfaProps = append(mfaProps, &MfaProps{
|
||||
Enabled: false,
|
||||
MfaType: SmsType,
|
||||
})
|
||||
for _, mfaType := range []string{SmsType, EmailType, TotpType} {
|
||||
mfaProps = append(mfaProps, user.GetMfaProps(mfaType, masked))
|
||||
}
|
||||
if user.MfaEmailEnabled {
|
||||
mfaProps = append(mfaProps, user.GetMfaProps(EmailType, masked))
|
||||
} else {
|
||||
mfaProps = append(mfaProps, &MfaProps{
|
||||
Enabled: false,
|
||||
MfaType: EmailType,
|
||||
})
|
||||
}
|
||||
|
||||
return mfaProps
|
||||
}
|
||||
|
||||
@ -121,6 +109,13 @@ func (user *User) GetMfaProps(mfaType string, masked bool) *MfaProps {
|
||||
mfaProps := &MfaProps{}
|
||||
|
||||
if mfaType == SmsType {
|
||||
if !user.MfaPhoneEnabled {
|
||||
return &MfaProps{
|
||||
Enabled: false,
|
||||
MfaType: mfaType,
|
||||
}
|
||||
}
|
||||
|
||||
mfaProps = &MfaProps{
|
||||
Enabled: user.MfaPhoneEnabled,
|
||||
MfaType: mfaType,
|
||||
@ -132,6 +127,13 @@ func (user *User) GetMfaProps(mfaType string, masked bool) *MfaProps {
|
||||
mfaProps.Secret = user.Phone
|
||||
}
|
||||
} else if mfaType == EmailType {
|
||||
if !user.MfaEmailEnabled {
|
||||
return &MfaProps{
|
||||
Enabled: false,
|
||||
MfaType: mfaType,
|
||||
}
|
||||
}
|
||||
|
||||
mfaProps = &MfaProps{
|
||||
Enabled: user.MfaEmailEnabled,
|
||||
MfaType: mfaType,
|
||||
@ -142,9 +144,22 @@ func (user *User) GetMfaProps(mfaType string, masked bool) *MfaProps {
|
||||
mfaProps.Secret = user.Email
|
||||
}
|
||||
} else if mfaType == TotpType {
|
||||
if user.TotpSecret == "" {
|
||||
return &MfaProps{
|
||||
Enabled: false,
|
||||
MfaType: mfaType,
|
||||
}
|
||||
}
|
||||
|
||||
mfaProps = &MfaProps{
|
||||
Enabled: true,
|
||||
MfaType: mfaType,
|
||||
}
|
||||
if masked {
|
||||
mfaProps.Secret = ""
|
||||
} else {
|
||||
mfaProps.Secret = user.TotpSecret
|
||||
}
|
||||
}
|
||||
|
||||
if user.PreferredMfaType == mfaType {
|
||||
@ -158,8 +173,9 @@ func DisabledMultiFactorAuth(user *User) error {
|
||||
user.RecoveryCodes = []string{}
|
||||
user.MfaPhoneEnabled = false
|
||||
user.MfaEmailEnabled = false
|
||||
user.TotpSecret = ""
|
||||
|
||||
_, err := UpdateUser(user.GetId(), user, []string{"preferred_mfa_type", "recovery_codes", "mfa_phone_enabled", "mfa_email_enabled"}, user.IsAdminUser())
|
||||
_, err := updateUser(user.GetId(), user, []string{"preferred_mfa_type", "recovery_codes", "mfa_phone_enabled", "mfa_email_enabled", "totp_secret"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -18,26 +18,24 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
|
||||
"github.com/beego/beego/context"
|
||||
"github.com/casdoor/casdoor/util"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
MfaSmsCountryCodeSession = "mfa_country_code"
|
||||
MfaSmsDestSession = "mfa_dest"
|
||||
MfaSmsRecoveryCodesSession = "mfa_recovery_codes"
|
||||
MfaSmsCountryCodeSession = "mfa_country_code"
|
||||
MfaSmsDestSession = "mfa_dest"
|
||||
)
|
||||
|
||||
type SmsMfa struct {
|
||||
Config *MfaProps
|
||||
}
|
||||
|
||||
func (mfa *SmsMfa) Initiate(ctx *context.Context, name string, secret string) (*MfaProps, error) {
|
||||
func (mfa *SmsMfa) Initiate(ctx *context.Context, userId string) (*MfaProps, error) {
|
||||
recoveryCode := uuid.NewString()
|
||||
|
||||
err := ctx.Input.CruSession.Set(MfaSmsRecoveryCodesSession, []string{recoveryCode})
|
||||
err := ctx.Input.CruSession.Set(MfaRecoveryCodesSession, []string{recoveryCode})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -63,9 +61,9 @@ func (mfa *SmsMfa) SetupVerify(ctx *context.Context, passCode string) error {
|
||||
}
|
||||
|
||||
func (mfa *SmsMfa) Enable(ctx *context.Context, user *User) error {
|
||||
recoveryCodes := ctx.Input.CruSession.Get(MfaSmsRecoveryCodesSession).([]string)
|
||||
recoveryCodes := ctx.Input.CruSession.Get(MfaRecoveryCodesSession).([]string)
|
||||
if len(recoveryCodes) == 0 {
|
||||
return fmt.Errorf("recovery codes is empty")
|
||||
return fmt.Errorf("recovery codes is missing")
|
||||
}
|
||||
|
||||
columns := []string{"recovery_codes", "preferred_mfa_type"}
|
||||
@ -111,7 +109,7 @@ func (mfa *SmsMfa) Verify(passCode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewSmsTwoFactor(config *MfaProps) *SmsMfa {
|
||||
func NewSmsMfaUtil(config *MfaProps) *SmsMfa {
|
||||
if config == nil {
|
||||
config = &MfaProps{
|
||||
MfaType: SmsType,
|
||||
@ -122,7 +120,7 @@ func NewSmsTwoFactor(config *MfaProps) *SmsMfa {
|
||||
}
|
||||
}
|
||||
|
||||
func NewEmailTwoFactor(config *MfaProps) *SmsMfa {
|
||||
func NewEmailMfaUtil(config *MfaProps) *SmsMfa {
|
||||
if config == nil {
|
||||
config = &MfaProps{
|
||||
MfaType: EmailType,
|
||||
|
133
object/mfa_totp.go
Normal file
133
object/mfa_totp.go
Normal file
@ -0,0 +1,133 @@
|
||||
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/beego/beego"
|
||||
"github.com/beego/beego/context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
const MfaTotpSecretSession = "mfa_totp_secret"
|
||||
|
||||
type TotpMfa struct {
|
||||
Config *MfaProps
|
||||
period uint
|
||||
secretSize uint
|
||||
digits otp.Digits
|
||||
}
|
||||
|
||||
func (mfa *TotpMfa) Initiate(ctx *context.Context, userId string) (*MfaProps, error) {
|
||||
issuer := beego.AppConfig.String("appname")
|
||||
if issuer == "" {
|
||||
issuer = "casdoor"
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: issuer,
|
||||
AccountName: userId,
|
||||
Period: mfa.period,
|
||||
SecretSize: mfa.secretSize,
|
||||
Digits: mfa.digits,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = ctx.Input.CruSession.Set(MfaTotpSecretSession, key.Secret())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recoveryCode := uuid.NewString()
|
||||
err = ctx.Input.CruSession.Set(MfaRecoveryCodesSession, []string{recoveryCode})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mfaProps := MfaProps{
|
||||
MfaType: mfa.Config.MfaType,
|
||||
RecoveryCodes: []string{recoveryCode},
|
||||
Secret: key.Secret(),
|
||||
URL: key.URL(),
|
||||
}
|
||||
return &mfaProps, nil
|
||||
}
|
||||
|
||||
func (mfa *TotpMfa) SetupVerify(ctx *context.Context, passcode string) error {
|
||||
secret := ctx.Input.CruSession.Get(MfaTotpSecretSession).(string)
|
||||
result := totp.Validate(passcode, secret)
|
||||
|
||||
if result {
|
||||
return nil
|
||||
} else {
|
||||
return errors.New("totp passcode error")
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *TotpMfa) Enable(ctx *context.Context, user *User) error {
|
||||
recoveryCodes := ctx.Input.CruSession.Get(MfaRecoveryCodesSession).([]string)
|
||||
if len(recoveryCodes) == 0 {
|
||||
return fmt.Errorf("recovery codes is missing")
|
||||
}
|
||||
secret := ctx.Input.CruSession.Get(MfaTotpSecretSession).(string)
|
||||
if secret == "" {
|
||||
return fmt.Errorf("totp secret is missing")
|
||||
}
|
||||
|
||||
columns := []string{"recovery_codes", "preferred_mfa_type", "totp_secret"}
|
||||
|
||||
user.RecoveryCodes = append(user.RecoveryCodes, recoveryCodes...)
|
||||
user.TotpSecret = secret
|
||||
if user.PreferredMfaType == "" {
|
||||
user.PreferredMfaType = mfa.Config.MfaType
|
||||
}
|
||||
|
||||
_, err := updateUser(user.GetId(), user, columns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mfa *TotpMfa) Verify(passcode string) error {
|
||||
result := totp.Validate(passcode, mfa.Config.Secret)
|
||||
|
||||
if result {
|
||||
return nil
|
||||
} else {
|
||||
return errors.New("totp passcode error")
|
||||
}
|
||||
}
|
||||
|
||||
func NewTotpMfaUtil(config *MfaProps) *TotpMfa {
|
||||
if config == nil {
|
||||
config = &MfaProps{
|
||||
MfaType: TotpType,
|
||||
}
|
||||
}
|
||||
|
||||
return &TotpMfa{
|
||||
Config: config,
|
||||
period: 30,
|
||||
secretSize: 20,
|
||||
digits: otp.DigitsSix,
|
||||
}
|
||||
}
|
@ -161,6 +161,7 @@ type User struct {
|
||||
WebauthnCredentials []webauthn.Credential `xorm:"webauthnCredentials blob" json:"webauthnCredentials"`
|
||||
PreferredMfaType string `xorm:"varchar(100)" json:"preferredMfaType"`
|
||||
RecoveryCodes []string `xorm:"varchar(1000)" json:"recoveryCodes,omitempty"`
|
||||
TotpSecret string `xorm:"varchar(100)" json:"totpSecret,omitempty"`
|
||||
MfaPhoneEnabled bool `json:"mfaPhoneEnabled"`
|
||||
MfaEmailEnabled bool `json:"mfaEmailEnabled"`
|
||||
MultiFactorAuths []*MfaProps `xorm:"-" json:"multiFactorAuths,omitempty"`
|
||||
@ -432,16 +433,19 @@ func GetMaskedUser(user *User, errs ...error) (*User, error) {
|
||||
if user.AccessSecret != "" {
|
||||
user.AccessSecret = "***"
|
||||
}
|
||||
if user.RecoveryCodes != nil {
|
||||
user.RecoveryCodes = nil
|
||||
}
|
||||
|
||||
if user.ManagedAccounts != nil {
|
||||
for _, manageAccount := range user.ManagedAccounts {
|
||||
manageAccount.Password = "***"
|
||||
}
|
||||
}
|
||||
|
||||
if user.TotpSecret != "" {
|
||||
user.TotpSecret = ""
|
||||
}
|
||||
if user.RecoveryCodes != nil {
|
||||
user.RecoveryCodes = nil
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user