diff --git a/dashboard/src/components/EnvComponents/Features.tsx b/dashboard/src/components/EnvComponents/Features.tsx index fda3149..c41f493 100644 --- a/dashboard/src/components/EnvComponents/Features.tsx +++ b/dashboard/src/components/EnvComponents/Features.tsx @@ -4,6 +4,7 @@ import InputField from '../InputField'; import { SwitchInputType } from '../../constants'; const Features = ({ variables, setVariables }: any) => { + // window.alert(variables) return (
{' '} @@ -24,6 +25,8 @@ const Features = ({ variables, setVariables }: any) => { /> + + Email Verification: @@ -97,6 +100,7 @@ const Features = ({ variables, setVariables }: any) => { also ignore the user MFA setting. + { /> + + { + !variables.DISABLE_MULTI_FACTOR_AUTHENTICATION && + + + TOTP: + + Note: to enable totp mfa + + + + + + + + } + {!variables.DISABLE_MULTI_FACTOR_AUTHENTICATION && + + + EMAIL OTP: + + Note: to enable email otp mfa + + + + + + + } + diff --git a/dashboard/src/constants.ts b/dashboard/src/constants.ts index 2dbf412..7a12131 100644 --- a/dashboard/src/constants.ts +++ b/dashboard/src/constants.ts @@ -85,6 +85,8 @@ export const SwitchInputType = { DISABLE_MULTI_FACTOR_AUTHENTICATION: 'DISABLE_MULTI_FACTOR_AUTHENTICATION', ENFORCE_MULTI_FACTOR_AUTHENTICATION: 'ENFORCE_MULTI_FACTOR_AUTHENTICATION', DISABLE_PLAYGROUND: 'DISABLE_PLAYGROUND', + DISABLE_TOTP_LOGIN: 'DISABLE_TOTP_LOGIN', + DISABLE_MAIL_OTP_LOGIN: 'DISABLE_MAIL_OTP_LOGIN', }; export const DateInputType = { @@ -169,6 +171,8 @@ export interface envVarTypes { DEFAULT_AUTHORIZE_RESPONSE_TYPE: string; DEFAULT_AUTHORIZE_RESPONSE_MODE: string; DISABLE_PLAYGROUND: boolean; + DISABLE_TOTP_LOGIN: boolean; + DISABLE_MAIL_OTP_LOGIN: boolean; } export const envSubViews = { diff --git a/dashboard/src/graphql/queries/index.ts b/dashboard/src/graphql/queries/index.ts index c5152f5..ffa8cd9 100644 --- a/dashboard/src/graphql/queries/index.ts +++ b/dashboard/src/graphql/queries/index.ts @@ -74,6 +74,8 @@ export const EnvVariablesQuery = ` DEFAULT_AUTHORIZE_RESPONSE_TYPE DEFAULT_AUTHORIZE_RESPONSE_MODE DISABLE_PLAYGROUND + DISABLE_TOTP_LOGIN + DISABLE_MAIL_OTP_LOGIN } } `; diff --git a/dashboard/src/pages/Environment.tsx b/dashboard/src/pages/Environment.tsx index c8d405c..8871f4a 100644 --- a/dashboard/src/pages/Environment.tsx +++ b/dashboard/src/pages/Environment.tsx @@ -94,6 +94,8 @@ const Environment = () => { DEFAULT_AUTHORIZE_RESPONSE_TYPE: '', DEFAULT_AUTHORIZE_RESPONSE_MODE: '', DISABLE_PLAYGROUND: false, + DISABLE_TOTP_LOGIN: false, + DISABLE_MAIL_OTP_LOGIN: true, }); const [fieldVisibility, setFieldVisibility] = React.useState< diff --git a/server/constants/env.go b/server/constants/env.go index 828d4b8..e89984b 100644 --- a/server/constants/env.go +++ b/server/constants/env.go @@ -160,6 +160,12 @@ const ( // EnvKeyDisableMultiFactorAuthentication is key for env variable DISABLE_MULTI_FACTOR_AUTHENTICATION // this variable is used to completely disable multi factor authentication. It will have no effect on profile preference EnvKeyDisableMultiFactorAuthentication = "DISABLE_MULTI_FACTOR_AUTHENTICATION" + // EnvKeyDisableTOTPLogin is key for env variable DISABLE_TOTP_LOGIN + // this variable is used to completely disable totp verification + EnvKeyDisableTOTPLogin = "DISABLE_TOTP_LOGIN" + // EnvKeyDisableMailOTPLogin is key for env variable DISABLE_MAIL_OTP_LOGIN + // this variable is used to completely disable totp verification + EnvKeyDisableMailOTPLogin = "DISABLE_MAIL_OTP_LOGIN" // EnvKeyDisablePhoneVerification is key for env variable DISABLE_PHONE_VERIFICATION // this variable is used to disable phone verification EnvKeyDisablePhoneVerification = "DISABLE_PHONE_VERIFICATION" diff --git a/server/crypto/rsa.go b/server/crypto/rsa.go index 35bebd3..6eba852 100644 --- a/server/crypto/rsa.go +++ b/server/crypto/rsa.go @@ -3,7 +3,9 @@ package crypto import ( "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/x509" + "encoding/base64" "encoding/pem" "errors" ) @@ -116,3 +118,24 @@ func AsRSAStr(privateKey *rsa.PrivateKey, publickKey *rsa.PublicKey) (string, st return privParsedPem, pubParsedPem, nil } + +func EncryptRSA(message string, key rsa.PublicKey) (string, error) { + label := []byte("OAEP Encrypted") + rng := rand.Reader + ciphertext, err := rsa.EncryptOAEP(sha256.New(), rng, &key, []byte(message), label) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func DecryptRSA(cipherText string, privateKey rsa.PrivateKey) (string, error) { + ct, _ := base64.StdEncoding.DecodeString(cipherText) + label := []byte("OAEP Encrypted") + rng := rand.Reader + plaintext, err := rsa.DecryptOAEP(sha256.New(), rng, &privateKey, ct, label) + if err != nil { + return "", err + } + return string(plaintext), nil +} diff --git a/server/db/models/user.go b/server/db/models/user.go index a262823..f0b1e4f 100644 --- a/server/db/models/user.go +++ b/server/db/models/user.go @@ -34,6 +34,8 @@ type User struct { UpdatedAt int64 `json:"updated_at" bson:"updated_at" cql:"updated_at" dynamo:"updated_at"` CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at" dynamo:"created_at"` AppData *string `json:"app_data" bson:"app_data" cql:"app_data" dynamo:"app_data"` + TotpSecret *string `json:"totp_secret" bson:"totp_secret" cql:"totp_secret" dynamo:"totp_secret"` + TotpVerified bool `json:"totp_verified" bson:"totp_verified" cql:"totp_verified" dynamo:"totp_verified"` } func (user *User) AsAPIUser() *model.User { diff --git a/server/db/providers/arangodb/totp.go b/server/db/providers/arangodb/totp.go new file mode 100644 index 0000000..35dd7a3 --- /dev/null +++ b/server/db/providers/arangodb/totp.go @@ -0,0 +1,70 @@ +package arangodb + +import ( + "bytes" + "context" + "fmt" + "image/png" + "time" + + "github.com/pquerna/otp/totp" + + "github.com/authorizerdev/authorizer/server/crypto" +) + +func (p *provider) GenerateTotp(ctx context.Context, id string) (*string, error) { + var buf bytes.Buffer + //get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("error while getting user details") + } + + // generate totp, TOTP hash is valid for 30 seconds + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "authorizer", + AccountName: user.Email, + }) + if err != nil { + return nil, fmt.Errorf("error while genrating totp") + } + + // get secret for user + secret := key.Secret() + + //generating image for key and encoding to base64 for displaying in frontend + img, err := key.Image(200, 200) + if err != nil { + return nil, fmt.Errorf("error while creating qr image for totp") + } + png.Encode(&buf, img) + encodedText := crypto.EncryptB64(buf.String()) + + // update user totp secret in db + user.UpdatedAt = time.Now().Unix() + user.TotpSecret = &secret + _, err = p.UpdateUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("error while updating user's totp secret") + } + + return &encodedText, nil +} + +func (p *provider) ValidatePasscode(ctx context.Context, passcode string, id string) (bool, error) { + // get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return false, fmt.Errorf("error while getting user details") + } + status := totp.Validate(passcode, *user.TotpSecret) + if !user.TotpVerified { + if status { + user.TotpVerified = true + p.UpdateUser(ctx, user) + return status, nil + } + return status, nil + } + return status, nil +} diff --git a/server/db/providers/cassandradb/provider.go b/server/db/providers/cassandradb/provider.go index 6f1fe6b..d2afcfc 100644 --- a/server/db/providers/cassandradb/provider.go +++ b/server/db/providers/cassandradb/provider.go @@ -267,6 +267,14 @@ func NewProvider() (*provider, error) { if err != nil { return nil, err } + // add totp_secret and totp_verified on users table + totpTableAlterQuery := fmt.Sprintf(`ALTER TABLE %s.%s ADD (totp_verified boolean, totp_secret text, app_data text)`, KeySpace, models.Collections.User) + err = session.Query(totpTableAlterQuery).Exec() + if err != nil { + log.Debug("Failed to alter table as column exists: ", err) + // return nil, err + } + return &provider{ db: session, }, err diff --git a/server/db/providers/cassandradb/totp.go b/server/db/providers/cassandradb/totp.go new file mode 100644 index 0000000..38965d7 --- /dev/null +++ b/server/db/providers/cassandradb/totp.go @@ -0,0 +1,70 @@ +package cassandradb + +import ( + "bytes" + "context" + "fmt" + "image/png" + "time" + + "github.com/pquerna/otp/totp" + + "github.com/authorizerdev/authorizer/server/crypto" +) + +func (p *provider) GenerateTotp(ctx context.Context, id string) (*string, error) { + var buf bytes.Buffer + //get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("error while getting user details") + } + + // generate totp, TOTP hash is valid for 30 seconds + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "authorizer", + AccountName: user.Email, + }) + if err != nil { + return nil, fmt.Errorf("error while genrating totp") + } + + // get secret for user + secret := key.Secret() + + //generating image for key and encoding to base64 for displaying in frontend + img, err := key.Image(200, 200) + if err != nil { + return nil, fmt.Errorf("error while creating qr image for totp") + } + png.Encode(&buf, img) + encodedText := crypto.EncryptB64(buf.String()) + + // update user totp secret in db + user.UpdatedAt = time.Now().Unix() + user.TotpSecret = &secret + _, err = p.UpdateUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("error while updating user's totp secret") + } + + return &encodedText, nil +} + +func (p *provider) ValidatePasscode(ctx context.Context, passcode string, id string) (bool, error) { + // get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return false, fmt.Errorf("error while getting user details") + } + status := totp.Validate(passcode, *user.TotpSecret) + if !user.TotpVerified { + if status { + user.TotpVerified = true + p.UpdateUser(ctx, user) + return status, nil + } + return status, nil + } + return status, nil +} diff --git a/server/db/providers/cassandradb/user.go b/server/db/providers/cassandradb/user.go index 376a6db..8b34a32 100644 --- a/server/db/providers/cassandradb/user.go +++ b/server/db/providers/cassandradb/user.go @@ -39,6 +39,7 @@ func (p *provider) AddUser(ctx context.Context, user *models.User) (*models.User user.CreatedAt = time.Now().Unix() user.UpdatedAt = time.Now().Unix() + user.TotpVerified = false bytes, err := json.Marshal(user) if err != nil { @@ -177,13 +178,19 @@ func (p *provider) ListUsers(ctx context.Context, pagination *model.Pagination) // there is no offset in cassandra // so we fetch till limit + offset // and return the results from offset to limit - query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, created_at, updated_at FROM %s LIMIT %d", KeySpace+"."+models.Collections.User, pagination.Limit+pagination.Offset) + query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, "+ + "nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled,"+ + " created_at, updated_at, totp_verified FROM %s LIMIT %d", KeySpace+"."+models.Collections.User, + pagination.Limit+pagination.Offset) scanner := p.db.Query(query).Iter().Scanner() counter := int64(0) for scanner.Next() { if counter >= pagination.Offset { var user models.User - err := scanner.Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.CreatedAt, &user.UpdatedAt) + err := scanner.Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, + &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, + &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, + &user.CreatedAt, &user.UpdatedAt, &user.TotpVerified) if err != nil { return nil, err } @@ -200,8 +207,8 @@ func (p *provider) ListUsers(ctx context.Context, pagination *model.Pagination) // GetUserByEmail to get user information from database using email address func (p *provider) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { var user models.User - query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, created_at, updated_at FROM %s WHERE email = '%s' LIMIT 1 ALLOW FILTERING", KeySpace+"."+models.Collections.User, email) - err := p.db.Query(query).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.CreatedAt, &user.UpdatedAt) + query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, created_at, updated_at, totp_secret, totp_verified, app_data FROM %s WHERE email = '%s' LIMIT 1 ALLOW FILTERING", KeySpace+"."+models.Collections.User, email) + err := p.db.Query(query).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.CreatedAt, &user.UpdatedAt, &user.TotpSecret, &user.TotpVerified, &user.AppData) if err != nil { return nil, err } @@ -211,8 +218,8 @@ func (p *provider) GetUserByEmail(ctx context.Context, email string) (*models.Us // GetUserByID to get user information from database using user ID func (p *provider) GetUserByID(ctx context.Context, id string) (*models.User, error) { var user models.User - query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, created_at, updated_at FROM %s WHERE id = '%s' LIMIT 1", KeySpace+"."+models.Collections.User, id) - err := p.db.Query(query).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.CreatedAt, &user.UpdatedAt) + query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, created_at, updated_at, totp_secret, totp_verified, app_data FROM %s WHERE id = '%s' LIMIT 1", KeySpace+"."+models.Collections.User, id) + err := p.db.Query(query).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.CreatedAt, &user.UpdatedAt, &user.TotpSecret, &user.TotpVerified, &user.AppData) if err != nil { return nil, err } diff --git a/server/db/providers/couchbase/totp.go b/server/db/providers/couchbase/totp.go new file mode 100644 index 0000000..cd8d56b --- /dev/null +++ b/server/db/providers/couchbase/totp.go @@ -0,0 +1,70 @@ +package couchbase + +import ( + "bytes" + "context" + "fmt" + "image/png" + "time" + + "github.com/pquerna/otp/totp" + + "github.com/authorizerdev/authorizer/server/crypto" +) + +func (p *provider) GenerateTotp(ctx context.Context, id string) (*string, error) { + var buf bytes.Buffer + //get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("error while getting user details") + } + + // generate totp, TOTP hash is valid for 30 seconds + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "authorizer", + AccountName: user.Email, + }) + if err != nil { + return nil, fmt.Errorf("error while genrating totp") + } + + // get secret for user + secret := key.Secret() + + //generating image for key and encoding to base64 for displaying in frontend + img, err := key.Image(200, 200) + if err != nil { + return nil, fmt.Errorf("error while creating qr image for totp") + } + png.Encode(&buf, img) + encodedText := crypto.EncryptB64(buf.String()) + + // update user totp secret in db + user.UpdatedAt = time.Now().Unix() + user.TotpSecret = &secret + _, err = p.UpdateUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("error while updating user's totp secret") + } + + return &encodedText, nil +} + +func (p *provider) ValidatePasscode(ctx context.Context, passcode string, id string) (bool, error) { + // get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return false, fmt.Errorf("error while getting user details") + } + status := totp.Validate(passcode, *user.TotpSecret) + if !user.TotpVerified { + if status { + user.TotpVerified = true + p.UpdateUser(ctx, user) + return status, nil + } + return status, nil + } + return status, nil +} diff --git a/server/db/providers/couchbase/user.go b/server/db/providers/couchbase/user.go index f5d2195..17c3d03 100644 --- a/server/db/providers/couchbase/user.go +++ b/server/db/providers/couchbase/user.go @@ -103,7 +103,7 @@ func (p *provider) ListUsers(ctx context.Context, pagination *model.Pagination) // GetUserByEmail to get user information from database using email address func (p *provider) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { var user *models.User - query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, created_at, updated_at FROM %s.%s WHERE email = $1 LIMIT 1", p.scopeName, models.Collections.User) + query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, created_at, updated_at, totp_secret, totp_verified FROM %s.%s WHERE email = $1 LIMIT 1", p.scopeName, models.Collections.User) q, err := p.db.Query(query, &gocb.QueryOptions{ ScanConsistency: gocb.QueryScanConsistencyRequestPlus, Context: ctx, @@ -122,7 +122,7 @@ func (p *provider) GetUserByEmail(ctx context.Context, email string) (*models.Us // GetUserByID to get user information from database using user ID func (p *provider) GetUserByID(ctx context.Context, id string) (*models.User, error) { var user *models.User - query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, created_at, updated_at FROM %s.%s WHERE _id = $1 LIMIT 1", p.scopeName, models.Collections.User) + query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, created_at, updated_at, totp_secret, totp_verified FROM %s.%s WHERE _id = $1 LIMIT 1", p.scopeName, models.Collections.User) q, err := p.db.Query(query, &gocb.QueryOptions{ ScanConsistency: gocb.QueryScanConsistencyRequestPlus, Context: ctx, diff --git a/server/db/providers/dynamodb/totp.go b/server/db/providers/dynamodb/totp.go new file mode 100644 index 0000000..844f57c --- /dev/null +++ b/server/db/providers/dynamodb/totp.go @@ -0,0 +1,70 @@ +package dynamodb + +import ( + "bytes" + "context" + "fmt" + "image/png" + "time" + + "github.com/pquerna/otp/totp" + + "github.com/authorizerdev/authorizer/server/crypto" +) + +func (p *provider) GenerateTotp(ctx context.Context, id string) (*string, error) { + var buf bytes.Buffer + //get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("error while getting user details") + } + + // generate totp, TOTP hash is valid for 30 seconds + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "authorizer", + AccountName: user.Email, + }) + if err != nil { + return nil, fmt.Errorf("error while genrating totp") + } + + // get secret for user + secret := key.Secret() + + //generating image for key and encoding to base64 for displaying in frontend + img, err := key.Image(200, 200) + if err != nil { + return nil, fmt.Errorf("error while creating qr image for totp") + } + png.Encode(&buf, img) + encodedText := crypto.EncryptB64(buf.String()) + + // update user totp secret in db + user.UpdatedAt = time.Now().Unix() + user.TotpSecret = &secret + _, err = p.UpdateUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("error while updating user's totp secret") + } + + return &encodedText, nil +} + +func (p *provider) ValidatePasscode(ctx context.Context, passcode string, id string) (bool, error) { + // get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return false, fmt.Errorf("error while getting user details") + } + status := totp.Validate(passcode, *user.TotpSecret) + if !user.TotpVerified { + if status { + user.TotpVerified = true + p.UpdateUser(ctx, user) + return status, nil + } + return status, nil + } + return status, nil +} diff --git a/server/db/providers/mongodb/totp.go b/server/db/providers/mongodb/totp.go new file mode 100644 index 0000000..8070850 --- /dev/null +++ b/server/db/providers/mongodb/totp.go @@ -0,0 +1,70 @@ +package mongodb + +import ( + "bytes" + "context" + "fmt" + "image/png" + "time" + + "github.com/pquerna/otp/totp" + + "github.com/authorizerdev/authorizer/server/crypto" +) + +func (p *provider) GenerateTotp(ctx context.Context, id string) (*string, error) { + var buf bytes.Buffer + //get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("error while getting user details") + } + + // generate totp, TOTP hash is valid for 30 seconds + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "authorizer", + AccountName: user.Email, + }) + if err != nil { + return nil, fmt.Errorf("error while genrating totp") + } + + // get secret for user + secret := key.Secret() + + //generating image for key and encoding to base64 for displaying in frontend + img, err := key.Image(200, 200) + if err != nil { + return nil, fmt.Errorf("error while creating qr image for totp") + } + png.Encode(&buf, img) + encodedText := crypto.EncryptB64(buf.String()) + + // update user totp secret in db + user.UpdatedAt = time.Now().Unix() + user.TotpSecret = &secret + _, err = p.UpdateUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("error while updating user's totp secret") + } + + return &encodedText, nil +} + +func (p *provider) ValidatePasscode(ctx context.Context, passcode string, id string) (bool, error) { + // get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return false, fmt.Errorf("error while getting user details") + } + status := totp.Validate(passcode, *user.TotpSecret) + if !user.TotpVerified { + if status { + user.TotpVerified = true + p.UpdateUser(ctx, user) + return status, nil + } + return status, nil + } + return status, nil +} diff --git a/server/db/providers/provider_template/totp.go b/server/db/providers/provider_template/totp.go new file mode 100644 index 0000000..4e349e5 --- /dev/null +++ b/server/db/providers/provider_template/totp.go @@ -0,0 +1,70 @@ +package provider_template + +import ( + "bytes" + "context" + "fmt" + "image/png" + "time" + + "github.com/pquerna/otp/totp" + + "github.com/authorizerdev/authorizer/server/crypto" +) + +func (p *provider) GenerateTotp(ctx context.Context, id string) (*string, error) { + var buf bytes.Buffer + //get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("error while getting user details") + } + + // generate totp, TOTP hash is valid for 30 seconds + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "authorizer", + AccountName: user.Email, + }) + if err != nil { + return nil, fmt.Errorf("error while genrating totp") + } + + // get secret for user + secret := key.Secret() + + //generating image for key and encoding to base64 for displaying in frontend + img, err := key.Image(200, 200) + if err != nil { + return nil, fmt.Errorf("error while creating qr image for totp") + } + png.Encode(&buf, img) + encodedText := crypto.EncryptB64(buf.String()) + + // update user totp secret in db + user.UpdatedAt = time.Now().Unix() + user.TotpSecret = &secret + _, err = p.UpdateUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("error while updating user's totp secret") + } + + return &encodedText, nil +} + +func (p *provider) ValidatePasscode(ctx context.Context, passcode string, id string) (bool, error) { + // get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return false, fmt.Errorf("error while getting user details") + } + status := totp.Validate(passcode, *user.TotpSecret) + if !user.TotpVerified { + if status { + user.TotpVerified = true + p.UpdateUser(ctx, user) + return status, nil + } + return status, nil + } + return status, nil +} diff --git a/server/db/providers/providers.go b/server/db/providers/providers.go index 65b9010..c34c34e 100644 --- a/server/db/providers/providers.go +++ b/server/db/providers/providers.go @@ -2,7 +2,6 @@ package providers import ( "context" - "github.com/authorizerdev/authorizer/server/db/models" "github.com/authorizerdev/authorizer/server/graph/model" ) @@ -88,4 +87,9 @@ type Provider interface { GetOTPByPhoneNumber(ctx context.Context, phoneNumber string) (*models.OTP, error) // DeleteOTP to delete otp DeleteOTP(ctx context.Context, otp *models.OTP) error + + // GenerateTotp to generate totp, store secret into db and returns base64 of QR code image + GenerateTotp(ctx context.Context, id string) (*string, error) + // ValidatePasscode validate user passcode with secret stored in our db + ValidatePasscode(ctx context.Context, passcode string, id string) (bool, error) } diff --git a/server/db/providers/sql/totp.go b/server/db/providers/sql/totp.go new file mode 100644 index 0000000..66a46aa --- /dev/null +++ b/server/db/providers/sql/totp.go @@ -0,0 +1,71 @@ +package sql + +import ( + "bytes" + "context" + "fmt" + log "github.com/sirupsen/logrus" + "image/png" + "time" + + "github.com/pquerna/otp/totp" + + "github.com/authorizerdev/authorizer/server/crypto" +) + +func (p *provider) GenerateTotp(ctx context.Context, id string) (*string, error) { + var buf bytes.Buffer + //get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("error while getting user details") + } + + // generate totp, TOTP hash is valid for 30 seconds + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "authorizer", + AccountName: user.Email, + }) + if err != nil { + return nil, fmt.Errorf("error while genrating totp") + } + + // get secret for user + secret := key.Secret() + + //generating image for key and encoding to base64 for displaying in frontend + img, err := key.Image(200, 200) + if err != nil { + return nil, fmt.Errorf("error while creating qr image for totp") + } + png.Encode(&buf, img) + encodedText := crypto.EncryptB64(buf.String()) + + // update user totp secret in db + user.UpdatedAt = time.Now().Unix() + user.TotpSecret = &secret + _, err = p.UpdateUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("error while updating user's totp secret") + } + log.Info("\n\n\n", &encodedText) + return &encodedText, nil +} + +func (p *provider) ValidatePasscode(ctx context.Context, passcode string, id string) (bool, error) { + // get user details + user, err := p.GetUserByID(ctx, id) + if err != nil { + return false, fmt.Errorf("error while getting user details") + } + status := totp.Validate(passcode, *user.TotpSecret) + if !user.TotpVerified { + if status { + user.TotpVerified = true + p.UpdateUser(ctx, user) + return status, nil + } + return status, nil + } + return status, nil +} diff --git a/server/env/env.go b/server/env/env.go index 3f65cf2..09ed82c 100644 --- a/server/env/env.go +++ b/server/env/env.go @@ -104,6 +104,8 @@ func InitAllEnv() error { osDisableStrongPassword := os.Getenv(constants.EnvKeyDisableStrongPassword) osEnforceMultiFactorAuthentication := os.Getenv(constants.EnvKeyEnforceMultiFactorAuthentication) osDisableMultiFactorAuthentication := os.Getenv(constants.EnvKeyDisableMultiFactorAuthentication) + osDisableTOTPLogin := os.Getenv(constants.EnvKeyDisableTOTPLogin) + osDisableMailOTPLogin := os.Getenv(constants.EnvKeyDisableMailOTPLogin) // phone verification var osDisablePhoneVerification := os.Getenv(constants.EnvKeyDisablePhoneVerification) osDisablePlayground := os.Getenv(constants.EnvKeyDisablePlayGround) @@ -689,20 +691,13 @@ func InitAllEnv() error { envData[constants.EnvKeyDisableEmailVerification] = true envData[constants.EnvKeyDisableMagicLinkLogin] = true envData[constants.EnvKeyIsEmailServiceEnabled] = false + envData[constants.EnvKeyDisableMailOTPLogin] = true } if envData[constants.EnvKeySmtpHost] != "" && envData[constants.EnvKeySmtpUsername] != "" && envData[constants.EnvKeySmtpPassword] != "" && envData[constants.EnvKeySenderEmail] != "" && envData[constants.EnvKeySmtpPort] != "" { envData[constants.EnvKeyIsEmailServiceEnabled] = true } - if envData[constants.EnvKeyEnforceMultiFactorAuthentication].(bool) && !envData[constants.EnvKeyIsEmailServiceEnabled].(bool) && !envData[constants.EnvKeyIsSMSServiceEnabled].(bool) { - return errors.New("to enable multi factor authentication, please enable email service") - } - - if !envData[constants.EnvKeyIsEmailServiceEnabled].(bool) { - envData[constants.EnvKeyDisableMultiFactorAuthentication] = true - } - if envData[constants.EnvKeyDisableEmailVerification].(bool) { envData[constants.EnvKeyDisableMagicLinkLogin] = true } @@ -840,6 +835,32 @@ func InitAllEnv() error { } } + if _, ok := envData[constants.EnvKeyDisableTOTPLogin]; !ok { + envData[constants.EnvKeyDisableTOTPLogin] = osDisableTOTPLogin == "false" + } + if osDisableTOTPLogin != "" { + boolValue, err := strconv.ParseBool(osDisableTOTPLogin) + if err != nil { + return err + } + if boolValue != envData[constants.EnvKeyDisableTOTPLogin].(bool) { + envData[constants.EnvKeyDisableTOTPLogin] = boolValue + } + } + + if _, ok := envData[constants.EnvKeyDisableMailOTPLogin]; !ok { + envData[constants.EnvKeyDisableMailOTPLogin] = osDisableMailOTPLogin == "true" + } + if osDisableMailOTPLogin != "" { + boolValue, err := strconv.ParseBool(osDisableMailOTPLogin) + if err != nil { + return err + } + if boolValue != envData[constants.EnvKeyDisableMailOTPLogin].(bool) { + envData[constants.EnvKeyDisableMailOTPLogin] = boolValue + } + } + err = memorystore.Provider.UpdateEnvStore(envData) if err != nil { log.Debug("Error while updating env store: ", err) diff --git a/server/env/persist_env.go b/server/env/persist_env.go index eb0b64f..56142c5 100644 --- a/server/env/persist_env.go +++ b/server/env/persist_env.go @@ -196,7 +196,7 @@ func PersistEnv() error { envValue := strings.TrimSpace(os.Getenv(key)) if envValue != "" { switch key { - case constants.EnvKeyIsProd, constants.EnvKeyDisableBasicAuthentication, constants.EnvKeyDisableMobileBasicAuthentication, constants.EnvKeyDisableEmailVerification, constants.EnvKeyDisableLoginPage, constants.EnvKeyDisableMagicLinkLogin, constants.EnvKeyDisableSignUp, constants.EnvKeyDisableRedisForEnv, constants.EnvKeyDisableStrongPassword, constants.EnvKeyIsEmailServiceEnabled, constants.EnvKeyIsSMSServiceEnabled, constants.EnvKeyEnforceMultiFactorAuthentication, constants.EnvKeyDisableMultiFactorAuthentication, constants.EnvKeyAdminCookieSecure, constants.EnvKeyAppCookieSecure, constants.EnvKeyDisablePhoneVerification, constants.EnvKeyDisablePlayGround: + case constants.EnvKeyIsProd, constants.EnvKeyDisableBasicAuthentication, constants.EnvKeyDisableMobileBasicAuthentication, constants.EnvKeyDisableEmailVerification, constants.EnvKeyDisableLoginPage, constants.EnvKeyDisableMagicLinkLogin, constants.EnvKeyDisableSignUp, constants.EnvKeyDisableRedisForEnv, constants.EnvKeyDisableStrongPassword, constants.EnvKeyIsEmailServiceEnabled, constants.EnvKeyIsSMSServiceEnabled, constants.EnvKeyEnforceMultiFactorAuthentication, constants.EnvKeyDisableMultiFactorAuthentication, constants.EnvKeyAdminCookieSecure, constants.EnvKeyAppCookieSecure, constants.EnvKeyDisablePhoneVerification, constants.EnvKeyDisablePlayGround, constants.EnvKeyDisableTOTPLogin, constants.EnvKeyDisableMailOTPLogin: if envValueBool, err := strconv.ParseBool(envValue); err == nil { if value.(bool) != envValueBool { storeData[key] = envValueBool @@ -227,6 +227,11 @@ func PersistEnv() error { storeData[constants.EnvKeyDisableMagicLinkLogin] = true hasChanged = true } + + if !storeData[constants.EnvKeyDisableMailOTPLogin].(bool) { + storeData[constants.EnvKeyDisableMailOTPLogin] = true + hasChanged = true + } } err = memorystore.Provider.UpdateEnvStore(storeData) diff --git a/server/go.mod b/server/go.mod index d1747e2..86efdc4 100644 --- a/server/go.mod +++ b/server/go.mod @@ -13,6 +13,7 @@ require ( github.com/go-playground/validator/v10 v10.11.1 // indirect github.com/goccy/go-json v0.9.11 // indirect github.com/gocql/gocql v1.2.0 + github.com/gokyle/twofactor v1.0.1 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-cmp v0.5.6 // indirect @@ -21,10 +22,12 @@ require ( github.com/joho/godotenv v1.3.0 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/pelletier/go-toml/v2 v2.0.5 // indirect + github.com/pquerna/otp v1.4.0 github.com/redis/go-redis/v9 v9.0.3 github.com/robertkrimen/otto v0.0.0-20211024170158-b87d35c0b86f github.com/sirupsen/logrus v1.8.1 github.com/stretchr/testify v1.8.0 + github.com/tuotoo/qrcode v0.0.0-20220425170535-52ccc2bebf5d github.com/twilio/twilio-go v1.7.2 github.com/vektah/gqlparser/v2 v2.5.1 go.mongodb.org/mongo-driver v1.8.1 diff --git a/server/go.sum b/server/go.sum index 4a2c928..467607b 100644 --- a/server/go.sum +++ b/server/go.sum @@ -56,8 +56,12 @@ github.com/aws/aws-sdk-go v1.44.298/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8 github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= +github.com/bits-and-blooms/bitset v1.2.1 h1:M+/hrU9xlMp7t4TyTDQW97d3tRPVuKFC6zBEK16QnXY= +github.com/bits-and-blooms/bitset v1.2.1/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= @@ -129,6 +133,8 @@ github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gocql/gocql v1.2.0 h1:TZhsCd7fRuye4VyHr3WCvWwIQaZUmjsqnSIXK9FcVCE= github.com/gocql/gocql v1.2.0/go.mod h1:3gM2c4D3AnkISwBxGnMMsS8Oy4y2lhbPRsH4xnJrHG8= +github.com/gokyle/twofactor v1.0.1 h1:uRhvx0S4Hb82RPIDALnf7QxbmPL49LyyaCkJDpWx+Ek= +github.com/gokyle/twofactor v1.0.1/go.mod h1:4gxzH1eaE/F3Pct/sCDNOylP0ClofUO5j4XZN9tKtLE= github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= @@ -254,6 +260,8 @@ github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ic github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q= github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw= github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc= +github.com/maruel/rs v1.1.0 h1:dh4OceAF5yD06EASOrb+DS358LI4g0B90YApSdjCP6U= +github.com/maruel/rs v1.1.0/go.mod h1:vzwMjzSJJxLIXmU62qHj6O5QRn5kvCKxFrfaFCxBcUY= github.com/matryer/moq v0.2.7/go.mod h1:kITsx543GOENm48TUAQyJ9+SAvFSr7iGQXPoth/VUBk= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= @@ -285,6 +293,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/redis/go-redis/v9 v9.0.3 h1:+7mmR26M0IvyLxGZUHxu4GiBkJkVDid0Un+j4ScYu4k= github.com/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= @@ -319,6 +329,8 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tuotoo/qrcode v0.0.0-20220425170535-52ccc2bebf5d h1:4x1FeGJRB00cvxnKXnRJDT89fvG/Lzm2ecm0vlr/qDs= +github.com/tuotoo/qrcode v0.0.0-20220425170535-52ccc2bebf5d/go.mod h1:uSELzeIcTceNCgzbKdJuJa0ouCqqtkyzL+6bnA3rM+M= github.com/twilio/twilio-go v1.7.2 h1:tX38DXbSuDWWIK+tKAE2AJSMR6d8i7lf9ksY8J29VLE= github.com/twilio/twilio-go v1.7.2/go.mod h1:tdnfQ5TjbewoAu4lf9bMsGvfuJ/QU9gYuv9yx3TSIXU= github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo= @@ -743,5 +755,7 @@ modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/server/graph/generated/generated.go b/server/graph/generated/generated.go index f323e42..4ad7cbe 100644 --- a/server/graph/generated/generated.go +++ b/server/graph/generated/generated.go @@ -52,6 +52,8 @@ type ComplexityRoot struct { RefreshToken func(childComplexity int) int ShouldShowEmailOtpScreen func(childComplexity int) int ShouldShowMobileOtpScreen func(childComplexity int) int + TotpBase64URL func(childComplexity int) int + TotpToken func(childComplexity int) int User func(childComplexity int) int } @@ -96,11 +98,13 @@ type ComplexityRoot struct { DisableEmailVerification func(childComplexity int) int DisableLoginPage func(childComplexity int) int DisableMagicLinkLogin func(childComplexity int) int + DisableMailOtpLogin func(childComplexity int) int DisableMultiFactorAuthentication func(childComplexity int) int DisablePlayground func(childComplexity int) int DisableRedisForEnv func(childComplexity int) int DisableSignUp func(childComplexity int) int DisableStrongPassword func(childComplexity int) int + DisableTotpLogin func(childComplexity int) int EnforceMultiFactorAuthentication func(childComplexity int) int FacebookClientID func(childComplexity int) int FacebookClientSecret func(childComplexity int) int @@ -201,6 +205,7 @@ type ComplexityRoot struct { UpdateWebhook func(childComplexity int, params model.UpdateWebhookRequest) int VerifyEmail func(childComplexity int, params model.VerifyEmailInput) int VerifyOtp func(childComplexity int, params model.VerifyOTPRequest) int + VerifyTotp func(childComplexity int, params model.VerifyTOTPRequest) int } Pagination struct { @@ -265,6 +270,7 @@ type ComplexityRoot struct { RevokedTimestamp func(childComplexity int) int Roles func(childComplexity int) int SignupMethods func(childComplexity int) int + TotpVerified func(childComplexity int) int UpdatedAt func(childComplexity int) int } @@ -347,6 +353,7 @@ type MutationResolver interface { Revoke(ctx context.Context, params model.OAuthRevokeInput) (*model.Response, error) VerifyOtp(ctx context.Context, params model.VerifyOTPRequest) (*model.AuthResponse, error) ResendOtp(ctx context.Context, params model.ResendOTPRequest) (*model.Response, error) + VerifyTotp(ctx context.Context, params model.VerifyTOTPRequest) (*model.AuthResponse, error) DeleteUser(ctx context.Context, params model.DeleteUserInput) (*model.Response, error) UpdateUser(ctx context.Context, params model.UpdateUserInput) (*model.User, error) AdminSignup(ctx context.Context, params model.AdminSignupInput) (*model.Response, error) @@ -446,6 +453,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.AuthResponse.ShouldShowMobileOtpScreen(childComplexity), true + case "AuthResponse.totp_base64_url": + if e.complexity.AuthResponse.TotpBase64URL == nil { + break + } + + return e.complexity.AuthResponse.TotpBase64URL(childComplexity), true + + case "AuthResponse.totp_token": + if e.complexity.AuthResponse.TotpToken == nil { + break + } + + return e.complexity.AuthResponse.TotpToken(childComplexity), true + case "AuthResponse.user": if e.complexity.AuthResponse.User == nil { break @@ -691,6 +712,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Env.DisableMagicLinkLogin(childComplexity), true + case "Env.DISABLE_MAIL_OTP_LOGIN": + if e.complexity.Env.DisableMailOtpLogin == nil { + break + } + + return e.complexity.Env.DisableMailOtpLogin(childComplexity), true + case "Env.DISABLE_MULTI_FACTOR_AUTHENTICATION": if e.complexity.Env.DisableMultiFactorAuthentication == nil { break @@ -726,6 +754,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Env.DisableStrongPassword(childComplexity), true + case "Env.DISABLE_TOTP_LOGIN": + if e.complexity.Env.DisableTotpLogin == nil { + break + } + + return e.complexity.Env.DisableTotpLogin(childComplexity), true + case "Env.ENFORCE_MULTI_FACTOR_AUTHENTICATION": if e.complexity.Env.EnforceMultiFactorAuthentication == nil { break @@ -1466,6 +1501,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.VerifyOtp(childComplexity, args["params"].(model.VerifyOTPRequest)), true + case "Mutation.verify_totp": + if e.complexity.Mutation.VerifyTotp == nil { + break + } + + args, err := ec.field_Mutation_verify_totp_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.VerifyTotp(childComplexity, args["params"].(model.VerifyTOTPRequest)), true + case "Pagination.limit": if e.complexity.Pagination.Limit == nil { break @@ -1838,6 +1885,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.User.SignupMethods(childComplexity), true + case "User.totp_verified": + if e.complexity.User.TotpVerified == nil { + break + } + + return e.complexity.User.TotpVerified(childComplexity), true + case "User.updated_at": if e.complexity.User.UpdatedAt == nil { break @@ -2139,6 +2193,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputValidateSessionInput, ec.unmarshalInputVerifyEmailInput, ec.unmarshalInputVerifyOTPRequest, + ec.unmarshalInputVerifyTOTPRequest, ec.unmarshalInputWebhookRequest, ) first := true @@ -2254,6 +2309,7 @@ type User { revoked_timestamp: Int64 is_multi_factor_auth_enabled: Boolean app_data: Map + totp_verified: Boolean } type Users { @@ -2301,6 +2357,8 @@ type AuthResponse { refresh_token: String expires_in: Int64 user: User + totp_base64_url: String + totp_token: String } type Response { @@ -2375,6 +2433,8 @@ type Env { DEFAULT_AUTHORIZE_RESPONSE_TYPE: String DEFAULT_AUTHORIZE_RESPONSE_MODE: String DISABLE_PLAYGROUND: Boolean! + DISABLE_MAIL_OTP_LOGIN: Boolean! + DISABLE_TOTP_LOGIN: Boolean! } type ValidateJWTTokenResponse { @@ -2498,6 +2558,8 @@ input UpdateEnvInput { DEFAULT_AUTHORIZE_RESPONSE_TYPE: String DEFAULT_AUTHORIZE_RESPONSE_MODE: String DISABLE_PLAYGROUND: Boolean + DISABLE_MAIL_OTP_LOGIN: Boolean + DISABLE_TOTP_LOGIN: Boolean } input AdminLoginInput { @@ -2760,6 +2822,12 @@ input VerifyOTPRequest { state: String } +input VerifyTOTPRequest { + otp: String! + token: String! + state: String +} + input ResendOTPRequest { email: String phone_number: String @@ -2789,6 +2857,7 @@ type Mutation { revoke(params: OAuthRevokeInput!): Response! verify_otp(params: VerifyOTPRequest!): AuthResponse! resend_otp(params: ResendOTPRequest!): Response! + verify_totp(params: VerifyTOTPRequest!): AuthResponse! # admin only apis _delete_user(params: DeleteUserInput!): Response! _update_user(params: UpdateUserInput!): User! @@ -3269,6 +3338,21 @@ func (ec *executionContext) field_Mutation_verify_otp_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Mutation_verify_totp_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.VerifyTOTPRequest + if tmp, ok := rawArgs["params"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + arg0, err = ec.unmarshalNVerifyTOTPRequest2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐVerifyTOTPRequest(ctx, tmp) + if err != nil { + return nil, err + } + } + args["params"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -3838,6 +3922,8 @@ func (ec *executionContext) fieldContext_AuthResponse_user(ctx context.Context, return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) + case "totp_verified": + return ec.fieldContext_User_totp_verified(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type User", field.Name) }, @@ -3845,6 +3931,88 @@ func (ec *executionContext) fieldContext_AuthResponse_user(ctx context.Context, return fc, nil } +func (ec *executionContext) _AuthResponse_totp_base64_url(ctx context.Context, field graphql.CollectedField, obj *model.AuthResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AuthResponse_totp_base64_url(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.TotpBase64URL, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AuthResponse_totp_base64_url(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthResponse", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AuthResponse_totp_token(ctx context.Context, field graphql.CollectedField, obj *model.AuthResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AuthResponse_totp_token(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.TotpToken, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AuthResponse_totp_token(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthResponse", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _EmailTemplate_id(ctx context.Context, field graphql.CollectedField, obj *model.EmailTemplate) (ret graphql.Marshaler) { fc, err := ec.fieldContext_EmailTemplate_id(ctx, field) if err != nil { @@ -6845,6 +7013,94 @@ func (ec *executionContext) fieldContext_Env_DISABLE_PLAYGROUND(ctx context.Cont return fc, nil } +func (ec *executionContext) _Env_DISABLE_MAIL_OTP_LOGIN(ctx context.Context, field graphql.CollectedField, obj *model.Env) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Env_DISABLE_MAIL_OTP_LOGIN(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.DisableMailOtpLogin, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Env_DISABLE_MAIL_OTP_LOGIN(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Env", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Env_DISABLE_TOTP_LOGIN(ctx context.Context, field graphql.CollectedField, obj *model.Env) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Env_DISABLE_TOTP_LOGIN(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.DisableTotpLogin, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Env_DISABLE_TOTP_LOGIN(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Env", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Error_message(ctx context.Context, field graphql.CollectedField, obj *model.Error) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Error_message(ctx, field) if err != nil { @@ -7179,6 +7435,8 @@ func (ec *executionContext) fieldContext_InviteMembersResponse_Users(ctx context return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) + case "totp_verified": + return ec.fieldContext_User_totp_verified(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type User", field.Name) }, @@ -7901,6 +8159,10 @@ func (ec *executionContext) fieldContext_Mutation_signup(ctx context.Context, fi return ec.fieldContext_AuthResponse_expires_in(ctx, field) case "user": return ec.fieldContext_AuthResponse_user(ctx, field) + case "totp_base64_url": + return ec.fieldContext_AuthResponse_totp_base64_url(ctx, field) + case "totp_token": + return ec.fieldContext_AuthResponse_totp_token(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) }, @@ -7974,6 +8236,10 @@ func (ec *executionContext) fieldContext_Mutation_mobile_signup(ctx context.Cont return ec.fieldContext_AuthResponse_expires_in(ctx, field) case "user": return ec.fieldContext_AuthResponse_user(ctx, field) + case "totp_base64_url": + return ec.fieldContext_AuthResponse_totp_base64_url(ctx, field) + case "totp_token": + return ec.fieldContext_AuthResponse_totp_token(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) }, @@ -8047,6 +8313,10 @@ func (ec *executionContext) fieldContext_Mutation_login(ctx context.Context, fie return ec.fieldContext_AuthResponse_expires_in(ctx, field) case "user": return ec.fieldContext_AuthResponse_user(ctx, field) + case "totp_base64_url": + return ec.fieldContext_AuthResponse_totp_base64_url(ctx, field) + case "totp_token": + return ec.fieldContext_AuthResponse_totp_token(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) }, @@ -8120,6 +8390,10 @@ func (ec *executionContext) fieldContext_Mutation_mobile_login(ctx context.Conte return ec.fieldContext_AuthResponse_expires_in(ctx, field) case "user": return ec.fieldContext_AuthResponse_user(ctx, field) + case "totp_base64_url": + return ec.fieldContext_AuthResponse_totp_base64_url(ctx, field) + case "totp_token": + return ec.fieldContext_AuthResponse_totp_token(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) }, @@ -8359,6 +8633,10 @@ func (ec *executionContext) fieldContext_Mutation_verify_email(ctx context.Conte return ec.fieldContext_AuthResponse_expires_in(ctx, field) case "user": return ec.fieldContext_AuthResponse_user(ctx, field) + case "totp_base64_url": + return ec.fieldContext_AuthResponse_totp_base64_url(ctx, field) + case "totp_token": + return ec.fieldContext_AuthResponse_totp_token(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) }, @@ -8668,6 +8946,10 @@ func (ec *executionContext) fieldContext_Mutation_verify_otp(ctx context.Context return ec.fieldContext_AuthResponse_expires_in(ctx, field) case "user": return ec.fieldContext_AuthResponse_user(ctx, field) + case "totp_base64_url": + return ec.fieldContext_AuthResponse_totp_base64_url(ctx, field) + case "totp_token": + return ec.fieldContext_AuthResponse_totp_token(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) }, @@ -8745,6 +9027,83 @@ func (ec *executionContext) fieldContext_Mutation_resend_otp(ctx context.Context return fc, nil } +func (ec *executionContext) _Mutation_verify_totp(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_verify_totp(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().VerifyTotp(rctx, fc.Args["params"].(model.VerifyTOTPRequest)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.AuthResponse) + fc.Result = res + return ec.marshalNAuthResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐAuthResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_verify_totp(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "message": + return ec.fieldContext_AuthResponse_message(ctx, field) + case "should_show_email_otp_screen": + return ec.fieldContext_AuthResponse_should_show_email_otp_screen(ctx, field) + case "should_show_mobile_otp_screen": + return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) + case "access_token": + return ec.fieldContext_AuthResponse_access_token(ctx, field) + case "id_token": + return ec.fieldContext_AuthResponse_id_token(ctx, field) + case "refresh_token": + return ec.fieldContext_AuthResponse_refresh_token(ctx, field) + case "expires_in": + return ec.fieldContext_AuthResponse_expires_in(ctx, field) + case "user": + return ec.fieldContext_AuthResponse_user(ctx, field) + case "totp_base64_url": + return ec.fieldContext_AuthResponse_totp_base64_url(ctx, field) + case "totp_token": + return ec.fieldContext_AuthResponse_totp_token(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_verify_totp_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return + } + return fc, nil +} + func (ec *executionContext) _Mutation__delete_user(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation__delete_user(ctx, field) if err != nil { @@ -8883,6 +9242,8 @@ func (ec *executionContext) fieldContext_Mutation__update_user(ctx context.Conte return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) + case "totp_verified": + return ec.fieldContext_User_totp_verified(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type User", field.Name) }, @@ -10090,6 +10451,10 @@ func (ec *executionContext) fieldContext_Query_session(ctx context.Context, fiel return ec.fieldContext_AuthResponse_expires_in(ctx, field) case "user": return ec.fieldContext_AuthResponse_user(ctx, field) + case "totp_base64_url": + return ec.fieldContext_AuthResponse_totp_base64_url(ctx, field) + case "totp_token": + return ec.fieldContext_AuthResponse_totp_token(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) }, @@ -10187,6 +10552,8 @@ func (ec *executionContext) fieldContext_Query_profile(ctx context.Context, fiel return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) + case "totp_verified": + return ec.fieldContext_User_totp_verified(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type User", field.Name) }, @@ -10456,6 +10823,8 @@ func (ec *executionContext) fieldContext_Query__user(ctx context.Context, field return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) + case "totp_verified": + return ec.fieldContext_User_totp_verified(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type User", field.Name) }, @@ -10746,6 +11115,10 @@ func (ec *executionContext) fieldContext_Query__env(ctx context.Context, field g return ec.fieldContext_Env_DEFAULT_AUTHORIZE_RESPONSE_MODE(ctx, field) case "DISABLE_PLAYGROUND": return ec.fieldContext_Env_DISABLE_PLAYGROUND(ctx, field) + case "DISABLE_MAIL_OTP_LOGIN": + return ec.fieldContext_Env_DISABLE_MAIL_OTP_LOGIN(ctx, field) + case "DISABLE_TOTP_LOGIN": + return ec.fieldContext_Env_DISABLE_TOTP_LOGIN(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Env", field.Name) }, @@ -12360,6 +12733,47 @@ func (ec *executionContext) fieldContext_User_app_data(ctx context.Context, fiel return fc, nil } +func (ec *executionContext) _User_totp_verified(ctx context.Context, field graphql.CollectedField, obj *model.User) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_User_totp_verified(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.TotpVerified, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*bool) + fc.Result = res + return ec.marshalOBoolean2ᚖbool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_User_totp_verified(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "User", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Users_pagination(ctx context.Context, field graphql.CollectedField, obj *model.Users) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Users_pagination(ctx, field) if err != nil { @@ -12493,6 +12907,8 @@ func (ec *executionContext) fieldContext_Users_users(ctx context.Context, field return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) + case "totp_verified": + return ec.fieldContext_User_totp_verified(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type User", field.Name) }, @@ -12708,6 +13124,8 @@ func (ec *executionContext) fieldContext_ValidateSessionResponse_user(ctx contex return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) + case "totp_verified": + return ec.fieldContext_User_totp_verified(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type User", field.Name) }, @@ -17132,7 +17550,7 @@ func (ec *executionContext) unmarshalInputUpdateEnvInput(ctx context.Context, ob asMap[k] = v } - fieldsInOrder := [...]string{"ACCESS_TOKEN_EXPIRY_TIME", "ADMIN_SECRET", "CUSTOM_ACCESS_TOKEN_SCRIPT", "OLD_ADMIN_SECRET", "SMTP_HOST", "SMTP_PORT", "SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_LOCAL_NAME", "SENDER_EMAIL", "SENDER_NAME", "JWT_TYPE", "JWT_SECRET", "JWT_PRIVATE_KEY", "JWT_PUBLIC_KEY", "ALLOWED_ORIGINS", "APP_URL", "RESET_PASSWORD_URL", "APP_COOKIE_SECURE", "ADMIN_COOKIE_SECURE", "DISABLE_EMAIL_VERIFICATION", "DISABLE_BASIC_AUTHENTICATION", "DISABLE_MAGIC_LINK_LOGIN", "DISABLE_LOGIN_PAGE", "DISABLE_SIGN_UP", "DISABLE_REDIS_FOR_ENV", "DISABLE_STRONG_PASSWORD", "DISABLE_MULTI_FACTOR_AUTHENTICATION", "ENFORCE_MULTI_FACTOR_AUTHENTICATION", "ROLES", "PROTECTED_ROLES", "DEFAULT_ROLES", "JWT_ROLE_CLAIM", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "FACEBOOK_CLIENT_ID", "FACEBOOK_CLIENT_SECRET", "LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET", "APPLE_CLIENT_ID", "APPLE_CLIENT_SECRET", "TWITTER_CLIENT_ID", "TWITTER_CLIENT_SECRET", "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_ACTIVE_DIRECTORY_TENANT_ID", "ORGANIZATION_NAME", "ORGANIZATION_LOGO", "DEFAULT_AUTHORIZE_RESPONSE_TYPE", "DEFAULT_AUTHORIZE_RESPONSE_MODE", "DISABLE_PLAYGROUND"} + fieldsInOrder := [...]string{"ACCESS_TOKEN_EXPIRY_TIME", "ADMIN_SECRET", "CUSTOM_ACCESS_TOKEN_SCRIPT", "OLD_ADMIN_SECRET", "SMTP_HOST", "SMTP_PORT", "SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_LOCAL_NAME", "SENDER_EMAIL", "SENDER_NAME", "JWT_TYPE", "JWT_SECRET", "JWT_PRIVATE_KEY", "JWT_PUBLIC_KEY", "ALLOWED_ORIGINS", "APP_URL", "RESET_PASSWORD_URL", "APP_COOKIE_SECURE", "ADMIN_COOKIE_SECURE", "DISABLE_EMAIL_VERIFICATION", "DISABLE_BASIC_AUTHENTICATION", "DISABLE_MAGIC_LINK_LOGIN", "DISABLE_LOGIN_PAGE", "DISABLE_SIGN_UP", "DISABLE_REDIS_FOR_ENV", "DISABLE_STRONG_PASSWORD", "DISABLE_MULTI_FACTOR_AUTHENTICATION", "ENFORCE_MULTI_FACTOR_AUTHENTICATION", "ROLES", "PROTECTED_ROLES", "DEFAULT_ROLES", "JWT_ROLE_CLAIM", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "FACEBOOK_CLIENT_ID", "FACEBOOK_CLIENT_SECRET", "LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET", "APPLE_CLIENT_ID", "APPLE_CLIENT_SECRET", "TWITTER_CLIENT_ID", "TWITTER_CLIENT_SECRET", "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_ACTIVE_DIRECTORY_TENANT_ID", "ORGANIZATION_NAME", "ORGANIZATION_LOGO", "DEFAULT_AUTHORIZE_RESPONSE_TYPE", "DEFAULT_AUTHORIZE_RESPONSE_MODE", "DISABLE_PLAYGROUND", "DISABLE_MAIL_OTP_LOGIN", "DISABLE_TOTP_LOGIN"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -17563,6 +17981,22 @@ func (ec *executionContext) unmarshalInputUpdateEnvInput(ctx context.Context, ob if err != nil { return it, err } + case "DISABLE_MAIL_OTP_LOGIN": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("DISABLE_MAIL_OTP_LOGIN")) + it.DisableMailOtpLogin, err = ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + case "DISABLE_TOTP_LOGIN": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("DISABLE_TOTP_LOGIN")) + it.DisableTotpLogin, err = ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } } } @@ -18069,6 +18503,50 @@ func (ec *executionContext) unmarshalInputVerifyOTPRequest(ctx context.Context, return it, nil } +func (ec *executionContext) unmarshalInputVerifyTOTPRequest(ctx context.Context, obj interface{}) (model.VerifyTOTPRequest, error) { + var it model.VerifyTOTPRequest + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"otp", "token", "state"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "otp": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("otp")) + it.Otp, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + case "token": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token")) + it.Token, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + case "state": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("state")) + it.State, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputWebhookRequest(ctx context.Context, obj interface{}) (model.WebhookRequest, error) { var it model.WebhookRequest asMap := map[string]interface{}{} @@ -18150,6 +18628,14 @@ func (ec *executionContext) _AuthResponse(ctx context.Context, sel ast.Selection out.Values[i] = ec._AuthResponse_user(ctx, field, obj) + case "totp_base64_url": + + out.Values[i] = ec._AuthResponse_totp_base64_url(ctx, field, obj) + + case "totp_token": + + out.Values[i] = ec._AuthResponse_totp_token(ctx, field, obj) + default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -18557,6 +19043,20 @@ func (ec *executionContext) _Env(ctx context.Context, sel ast.SelectionSet, obj out.Values[i] = ec._Env_DISABLE_PLAYGROUND(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "DISABLE_MAIL_OTP_LOGIN": + + out.Values[i] = ec._Env_DISABLE_MAIL_OTP_LOGIN(ctx, field, obj) + + if out.Values[i] == graphql.Null { + invalids++ + } + case "DISABLE_TOTP_LOGIN": + + out.Values[i] = ec._Env_DISABLE_TOTP_LOGIN(ctx, field, obj) + if out.Values[i] == graphql.Null { invalids++ } @@ -18942,6 +19442,15 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) return ec._Mutation_resend_otp(ctx, field) }) + if out.Values[i] == graphql.Null { + invalids++ + } + case "verify_totp": + + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_verify_totp(ctx, field) + }) + if out.Values[i] == graphql.Null { invalids++ } @@ -19744,6 +20253,10 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj out.Values[i] = ec._User_app_data(ctx, field, obj) + case "totp_verified": + + out.Values[i] = ec._User_totp_verified(ctx, field, obj) + default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -21024,6 +21537,11 @@ func (ec *executionContext) unmarshalNVerifyOTPRequest2githubᚗcomᚋauthorizer return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalNVerifyTOTPRequest2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐVerifyTOTPRequest(ctx context.Context, v interface{}) (model.VerifyTOTPRequest, error) { + res, err := ec.unmarshalInputVerifyTOTPRequest(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNWebhook2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐWebhook(ctx context.Context, sel ast.SelectionSet, v model.Webhook) graphql.Marshaler { return ec._Webhook(ctx, sel, &v) } diff --git a/server/graph/model/models_gen.go b/server/graph/model/models_gen.go index 4cdd8d0..d5ae277 100644 --- a/server/graph/model/models_gen.go +++ b/server/graph/model/models_gen.go @@ -34,6 +34,8 @@ type AuthResponse struct { RefreshToken *string `json:"refresh_token"` ExpiresIn *int64 `json:"expires_in"` User *User `json:"user"` + TotpBase64URL *string `json:"totp_base64_url"` + TotpToken *string `json:"totp_token"` } type DeleteEmailTemplateRequest struct { @@ -122,6 +124,8 @@ type Env struct { DefaultAuthorizeResponseType *string `json:"DEFAULT_AUTHORIZE_RESPONSE_TYPE"` DefaultAuthorizeResponseMode *string `json:"DEFAULT_AUTHORIZE_RESPONSE_MODE"` DisablePlayground bool `json:"DISABLE_PLAYGROUND"` + DisableMailOtpLogin bool `json:"DISABLE_MAIL_OTP_LOGIN"` + DisableTotpLogin bool `json:"DISABLE_TOTP_LOGIN"` } type Error struct { @@ -381,6 +385,8 @@ type UpdateEnvInput struct { DefaultAuthorizeResponseType *string `json:"DEFAULT_AUTHORIZE_RESPONSE_TYPE"` DefaultAuthorizeResponseMode *string `json:"DEFAULT_AUTHORIZE_RESPONSE_MODE"` DisablePlayground *bool `json:"DISABLE_PLAYGROUND"` + DisableMailOtpLogin *bool `json:"DISABLE_MAIL_OTP_LOGIN"` + DisableTotpLogin *bool `json:"DISABLE_TOTP_LOGIN"` } type UpdateProfileInput struct { @@ -447,6 +453,7 @@ type User struct { RevokedTimestamp *int64 `json:"revoked_timestamp"` IsMultiFactorAuthEnabled *bool `json:"is_multi_factor_auth_enabled"` AppData map[string]interface{} `json:"app_data"` + TotpVerified *bool `json:"totp_verified"` } type Users struct { @@ -504,6 +511,12 @@ type VerifyOTPRequest struct { State *string `json:"state"` } +type VerifyTOTPRequest struct { + Otp string `json:"otp"` + Token string `json:"token"` + State *string `json:"state"` +} + type Webhook struct { ID string `json:"id"` EventName *string `json:"event_name"` diff --git a/server/graph/schema.graphqls b/server/graph/schema.graphqls index 1c7de22..bcb0b07 100644 --- a/server/graph/schema.graphqls +++ b/server/graph/schema.graphqls @@ -52,6 +52,7 @@ type User { revoked_timestamp: Int64 is_multi_factor_auth_enabled: Boolean app_data: Map + totp_verified: Boolean } type Users { @@ -99,6 +100,8 @@ type AuthResponse { refresh_token: String expires_in: Int64 user: User + totp_base64_url: String + totp_token: String } type Response { @@ -173,6 +176,8 @@ type Env { DEFAULT_AUTHORIZE_RESPONSE_TYPE: String DEFAULT_AUTHORIZE_RESPONSE_MODE: String DISABLE_PLAYGROUND: Boolean! + DISABLE_MAIL_OTP_LOGIN: Boolean! + DISABLE_TOTP_LOGIN: Boolean! } type ValidateJWTTokenResponse { @@ -296,6 +301,8 @@ input UpdateEnvInput { DEFAULT_AUTHORIZE_RESPONSE_TYPE: String DEFAULT_AUTHORIZE_RESPONSE_MODE: String DISABLE_PLAYGROUND: Boolean + DISABLE_MAIL_OTP_LOGIN: Boolean + DISABLE_TOTP_LOGIN: Boolean } input AdminLoginInput { @@ -558,6 +565,12 @@ input VerifyOTPRequest { state: String } +input VerifyTOTPRequest { + otp: String! + token: String! + state: String +} + input ResendOTPRequest { email: String phone_number: String @@ -587,6 +600,7 @@ type Mutation { revoke(params: OAuthRevokeInput!): Response! verify_otp(params: VerifyOTPRequest!): AuthResponse! resend_otp(params: ResendOTPRequest!): Response! + verify_totp(params: VerifyTOTPRequest!): AuthResponse! # admin only apis _delete_user(params: DeleteUserInput!): Response! _update_user(params: UpdateUserInput!): User! diff --git a/server/graph/schema.resolvers.go b/server/graph/schema.resolvers.go index eecb6b2..fed9c4c 100644 --- a/server/graph/schema.resolvers.go +++ b/server/graph/schema.resolvers.go @@ -81,6 +81,11 @@ func (r *mutationResolver) ResendOtp(ctx context.Context, params model.ResendOTP return resolvers.ResendOTPResolver(ctx, params) } +// VerifyTotp is the resolver for the verify_totp field. +func (r *mutationResolver) VerifyTotp(ctx context.Context, params model.VerifyTOTPRequest) (*model.AuthResponse, error) { + return resolvers.VerifyTotpResolver(ctx, params) +} + // DeleteUser is the resolver for the _delete_user field. func (r *mutationResolver) DeleteUser(ctx context.Context, params model.DeleteUserInput) (*model.Response, error) { return resolvers.DeleteUserResolver(ctx, params) diff --git a/server/memorystore/memory_store.go b/server/memorystore/memory_store.go index 4004d68..a143d04 100644 --- a/server/memorystore/memory_store.go +++ b/server/memorystore/memory_store.go @@ -36,9 +36,11 @@ func InitMemStore() error { constants.EnvKeyIsSMSServiceEnabled: false, constants.EnvKeyEnforceMultiFactorAuthentication: false, constants.EnvKeyDisableMultiFactorAuthentication: false, + constants.EnvKeyDisableTOTPLogin: false, constants.EnvKeyAppCookieSecure: true, constants.EnvKeyAdminCookieSecure: true, constants.EnvKeyDisablePlayGround: true, + constants.EnvKeyDisableMailOTPLogin: true, } requiredEnvs := RequiredEnvStoreObj.GetRequiredEnv() diff --git a/server/memorystore/providers/redis/store.go b/server/memorystore/providers/redis/store.go index d761ce1..a1b21db 100644 --- a/server/memorystore/providers/redis/store.go +++ b/server/memorystore/providers/redis/store.go @@ -176,7 +176,7 @@ func (c *provider) GetEnvStore() (map[string]interface{}, error) { return nil, err } for key, value := range data { - if key == constants.EnvKeyDisableBasicAuthentication || key == constants.EnvKeyDisableMobileBasicAuthentication || key == constants.EnvKeyDisableEmailVerification || key == constants.EnvKeyDisableLoginPage || key == constants.EnvKeyDisableMagicLinkLogin || key == constants.EnvKeyDisableRedisForEnv || key == constants.EnvKeyDisableSignUp || key == constants.EnvKeyDisableStrongPassword || key == constants.EnvKeyIsEmailServiceEnabled || key == constants.EnvKeyIsSMSServiceEnabled || key == constants.EnvKeyEnforceMultiFactorAuthentication || key == constants.EnvKeyDisableMultiFactorAuthentication || key == constants.EnvKeyAppCookieSecure || key == constants.EnvKeyAdminCookieSecure || key == constants.EnvKeyDisablePlayGround { + if key == constants.EnvKeyDisableBasicAuthentication || key == constants.EnvKeyDisableMobileBasicAuthentication || key == constants.EnvKeyDisableEmailVerification || key == constants.EnvKeyDisableLoginPage || key == constants.EnvKeyDisableMagicLinkLogin || key == constants.EnvKeyDisableRedisForEnv || key == constants.EnvKeyDisableSignUp || key == constants.EnvKeyDisableStrongPassword || key == constants.EnvKeyIsEmailServiceEnabled || key == constants.EnvKeyIsSMSServiceEnabled || key == constants.EnvKeyEnforceMultiFactorAuthentication || key == constants.EnvKeyDisableMultiFactorAuthentication || key == constants.EnvKeyAppCookieSecure || key == constants.EnvKeyAdminCookieSecure || key == constants.EnvKeyDisablePlayGround || key == constants.EnvKeyDisableTOTPLogin || key == constants.EnvKeyDisableMailOTPLogin { boolValue, err := strconv.ParseBool(value) if err != nil { return res, err diff --git a/server/resolvers/env.go b/server/resolvers/env.go index b7a949a..5eb86bd 100644 --- a/server/resolvers/env.go +++ b/server/resolvers/env.go @@ -203,6 +203,8 @@ func EnvResolver(ctx context.Context) (*model.Env, error) { res.AdminCookieSecure = store[constants.EnvKeyAdminCookieSecure].(bool) res.AppCookieSecure = store[constants.EnvKeyAppCookieSecure].(bool) res.DisablePlayground = store[constants.EnvKeyDisablePlayGround].(bool) + res.DisableMailOtpLogin = store[constants.EnvKeyDisableMailOTPLogin].(bool) + res.DisableTotpLogin = store[constants.EnvKeyDisableTOTPLogin].(bool) return res, nil } diff --git a/server/resolvers/login.go b/server/resolvers/login.go index 6b16c3e..78a1f7f 100644 --- a/server/resolvers/login.go +++ b/server/resolvers/login.go @@ -7,11 +7,13 @@ import ( "time" "github.com/google/uuid" - log "github.com/sirupsen/logrus" "golang.org/x/crypto/bcrypt" + log "github.com/sirupsen/logrus" + "github.com/authorizerdev/authorizer/server/constants" "github.com/authorizerdev/authorizer/server/cookie" + "github.com/authorizerdev/authorizer/server/crypto" "github.com/authorizerdev/authorizer/server/db" "github.com/authorizerdev/authorizer/server/db/models" "github.com/authorizerdev/authorizer/server/email" @@ -110,8 +112,18 @@ func LoginResolver(ctx context.Context, params model.LoginInput) (*model.AuthRes log.Debug("MFA service not enabled: ", err) } + isTOTPLoginDisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableTOTPLogin) + if err != nil || !isTOTPLoginDisabled { + log.Debug("totp service not enabled: ", err) + } + + isMailOTPDisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableMailOTPLogin) + if err != nil || !isMailOTPDisabled { + log.Debug("mail OTP service not enabled: ", err) + } + // If email service is not enabled continue the process in any way - if refs.BoolValue(user.IsMultiFactorAuthEnabled) && isEmailServiceEnabled && !isMFADisabled { + if refs.BoolValue(user.IsMultiFactorAuthEnabled) && !isMailOTPDisabled && !isMFADisabled { otp := utils.GenerateOTP() expires := time.Now().Add(1 * time.Minute).Unix() otpData, err := db.Provider.UpsertOTP(ctx, &models.OTP{ @@ -150,6 +162,47 @@ func LoginResolver(ctx context.Context, params model.LoginInput) (*model.AuthRes }, nil } + // if mfa enabled and also totp enabled + if !isMFADisabled && refs.BoolValue(user.IsMultiFactorAuthEnabled) && !isTOTPLoginDisabled { + pubKey, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyJwtPublicKey) + if err != nil { + log.Debug("error while getting public key") + } + + publicKey, err := crypto.ParseRsaPublicKeyFromPemStr(pubKey) + if err != nil { + log.Debug("error while parsing public key") + } + + //encrypting user id, so it can be used as token for verifying + encryptedUserId, err := crypto.EncryptRSA(user.ID, *publicKey) + if err != nil { + log.Debug("error while encrypting user id") + } + + // for first time user or whose totp is not verified + if !user.TotpVerified { + base64URL, err := db.Provider.GenerateTotp(ctx, user.ID) + if err != nil { + log.Debug("error while generating base64 url: ", err) + } + // when user is first time registering for totp + res = &model.AuthResponse{ + Message: `Proceed to totp screen`, + TotpBase64URL: base64URL, + TotpToken: &encryptedUserId, + } + return res, nil + } else { + //when user is already register for totp + res = &model.AuthResponse{ + Message: `Proceed to totp screen`, + TotpToken: &encryptedUserId, + } + return res, nil + } + } + code := "" codeChallenge := "" nonce := "" diff --git a/server/resolvers/update_env.go b/server/resolvers/update_env.go index 96388aa..6ac770c 100644 --- a/server/resolvers/update_env.go +++ b/server/resolvers/update_env.go @@ -253,13 +253,15 @@ func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model // in case SMTP is off but env is set to true if updatedData[constants.EnvKeySmtpHost] == "" || updatedData[constants.EnvKeySmtpUsername] == "" || updatedData[constants.EnvKeySmtpPassword] == "" || updatedData[constants.EnvKeySenderEmail] == "" && updatedData[constants.EnvKeySmtpPort] == "" { updatedData[constants.EnvKeyIsEmailServiceEnabled] = false - updatedData[constants.EnvKeyDisableMultiFactorAuthentication] = true if !updatedData[constants.EnvKeyDisableEmailVerification].(bool) { updatedData[constants.EnvKeyDisableEmailVerification] = true } - + if !updatedData[constants.EnvKeyDisableMailOTPLogin].(bool) { + updatedData[constants.EnvKeyDisableMailOTPLogin] = true + } if !updatedData[constants.EnvKeyDisableMagicLinkLogin].(bool) { - updatedData[constants.EnvKeyDisableMagicLinkLogin] = true + updatedData[constants.EnvKeyDisableMailOTPLogin] = true + updatedData[constants.EnvKeyDisableTOTPLogin] = false } } @@ -274,6 +276,21 @@ func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model } } + if updatedData[constants.EnvKeyDisableMultiFactorAuthentication].(bool) { + updatedData[constants.EnvKeyDisableTOTPLogin] = true + updatedData[constants.EnvKeyDisableMailOTPLogin] = true + } else { + if !updatedData[constants.EnvKeyDisableMailOTPLogin].(bool) && !updatedData[constants.EnvKeyDisableTOTPLogin].(bool) { + errors.New("can't enable both mfa methods at same time") + updatedData[constants.EnvKeyDisableMailOTPLogin] = true + updatedData[constants.EnvKeyDisableTOTPLogin] = false + } else if updatedData[constants.EnvKeyDisableMailOTPLogin].(bool) && updatedData[constants.EnvKeyDisableTOTPLogin].(bool) { + errors.New("can't disable both mfa methods at same time") + updatedData[constants.EnvKeyDisableMailOTPLogin] = true + updatedData[constants.EnvKeyDisableTOTPLogin] = false + } + } + if !currentData[constants.EnvKeyEnforceMultiFactorAuthentication].(bool) && updatedData[constants.EnvKeyEnforceMultiFactorAuthentication].(bool) && !updatedData[constants.EnvKeyDisableMultiFactorAuthentication].(bool) { go db.Provider.UpdateUsers(ctx, map[string]interface{}{ "is_multi_factor_auth_enabled": true, diff --git a/server/resolvers/verify_totp.go b/server/resolvers/verify_totp.go new file mode 100644 index 0000000..900aaff --- /dev/null +++ b/server/resolvers/verify_totp.go @@ -0,0 +1,136 @@ +package resolvers + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + log "github.com/sirupsen/logrus" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/cookie" + "github.com/authorizerdev/authorizer/server/crypto" + "github.com/authorizerdev/authorizer/server/db" + "github.com/authorizerdev/authorizer/server/db/models" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/memorystore" + "github.com/authorizerdev/authorizer/server/refs" + "github.com/authorizerdev/authorizer/server/token" + "github.com/authorizerdev/authorizer/server/utils" +) + +// VerifyTotpResolver resolver for verify totp mutation +func VerifyTotpResolver(ctx context.Context, params model.VerifyTOTPRequest) (*model.AuthResponse, error) { + var res *model.AuthResponse + + encryptedkey := params.Token + + pvtKey, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyJwtPrivateKey) + if err != nil { + log.Debug("error while getting private key") + } + + privateKey, err := crypto.ParseRsaPrivateKeyFromPemStr(pvtKey) + if err != nil { + log.Debug("error while parsing private key") + } + + userID, err := crypto.DecryptRSA(encryptedkey, *privateKey) + if err != nil { + log.Debug("error while decrypting userId") + } + + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + log.Debug("Failed to get GinContext: ", err) + return res, err + } + + user, err := db.Provider.GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + + status, err := db.Provider.ValidatePasscode(ctx, params.Otp, userID) + if err != nil || !status { + return nil, fmt.Errorf("error while validating passcode") + } + + code := "" + codeChallenge := "" + nonce := "" + + roles := strings.Split(user.Roles, ",") + scope := []string{"openid", "email", "profile"} + + // Get state from store + if params.State != nil { + authorizeState, _ := memorystore.Provider.GetState(refs.StringValue(params.State)) + if authorizeState != "" { + authorizeStateSplit := strings.Split(authorizeState, "@@") + if len(authorizeStateSplit) > 1 { + code = authorizeStateSplit[0] + codeChallenge = authorizeStateSplit[1] + } else { + nonce = authorizeState + } + go memorystore.Provider.RemoveState(refs.StringValue(params.State)) + } + } + + if nonce == "" { + nonce = uuid.New().String() + } + + authToken, err := token.CreateAuthToken(gc, user, roles, scope, constants.AuthRecipeMethodBasicAuth, nonce, code) + if err != nil { + log.Debug("Failed to create auth token", err) + return res, err + } + + // TODO add to other login options as well + // Code challenge could be optional if PKCE flow is not used + if code != "" { + if err := memorystore.Provider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash); err != nil { + log.Debug("SetState failed: ", err) + return res, err + } + } + + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } + + res = &model.AuthResponse{ + Message: `Logged in successfully`, + AccessToken: &authToken.AccessToken.Token, + IDToken: &authToken.IDToken.Token, + ExpiresIn: &expiresIn, + User: user.AsAPIUser(), + } + + cookie.SetSession(gc, authToken.FingerPrintHash) + sessionStoreKey := constants.AuthRecipeMethodBasicAuth + ":" + user.ID + memorystore.Provider.SetUserSession(sessionStoreKey, constants.TokenTypeSessionToken+"_"+authToken.FingerPrint, authToken.FingerPrintHash, authToken.SessionTokenExpiresAt) + memorystore.Provider.SetUserSession(sessionStoreKey, constants.TokenTypeAccessToken+"_"+authToken.FingerPrint, authToken.AccessToken.Token, authToken.AccessToken.ExpiresAt) + + if authToken.RefreshToken != nil { + res.RefreshToken = &authToken.RefreshToken.Token + memorystore.Provider.SetUserSession(sessionStoreKey, constants.TokenTypeRefreshToken+"_"+authToken.FingerPrint, authToken.RefreshToken.Token, authToken.RefreshToken.ExpiresAt) + } + + go func() { + utils.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) + db.Provider.AddSession(ctx, &models.Session{ + UserID: user.ID, + UserAgent: utils.GetUserAgent(gc.Request), + IP: utils.GetIP(gc.Request), + }) + }() + + return res, nil +} diff --git a/server/test/integration_test.go b/server/test/integration_test.go index 3d4bb3d..415cb0a 100644 --- a/server/test/integration_test.go +++ b/server/test/integration_test.go @@ -141,6 +141,7 @@ func TestResolvers(t *testing.T) { inviteUserTest(t, s) validateJwtTokenTest(t, s) verifyOTPTest(t, s) + verifyTOTPTest(t, s) resendOTPTest(t, s) validateSessionTests(t, s) diff --git a/server/test/resend_otp_test.go b/server/test/resend_otp_test.go index 3f1e738..ffc0222 100644 --- a/server/test/resend_otp_test.go +++ b/server/test/resend_otp_test.go @@ -54,6 +54,9 @@ func resendOTPTest(t *testing.T, s TestSetup) { }) assert.NoError(t, err) assert.NotNil(t, updateRes) + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableMailOTPLogin, false) + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableTOTPLogin, true) + // Resend otp should return error as no initial opt is being sent resendOtpRes, err := resolvers.ResendOTPResolver(ctx, model.ResendOTPRequest{ Email: refs.NewStringRef(email), diff --git a/server/test/verify_otp_test.go b/server/test/verify_otp_test.go index 455ac12..ca7b2fa 100644 --- a/server/test/verify_otp_test.go +++ b/server/test/verify_otp_test.go @@ -54,6 +54,8 @@ func verifyOTPTest(t *testing.T, s TestSetup) { }) assert.NoError(t, err) assert.NotEmpty(t, updateProfileRes.Message) + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableMailOTPLogin, false) + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableTOTPLogin, true) // Login should not return error but access token should be empty as otp should have been sent loginRes, err = resolvers.LoginResolver(ctx, model.LoginInput{ diff --git a/server/test/verify_totp_test.go b/server/test/verify_totp_test.go new file mode 100644 index 0000000..0bb28de --- /dev/null +++ b/server/test/verify_totp_test.go @@ -0,0 +1,149 @@ +package test + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/gokyle/twofactor" + "github.com/stretchr/testify/assert" + "github.com/tuotoo/qrcode" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/db" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/memorystore" + "github.com/authorizerdev/authorizer/server/refs" + "github.com/authorizerdev/authorizer/server/resolvers" + "github.com/authorizerdev/authorizer/server/token" +) + +func verifyTOTPTest(t *testing.T, s TestSetup) { + t.Helper() + t.Run(`should verify totp`, func(t *testing.T) { + req, ctx := createContext(s) + email := "verify_totp." + s.TestInfo.Email + cleanData(email) + res, err := resolvers.SignupResolver(ctx, model.SignUpInput{ + Email: email, + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password, + }) + assert.NoError(t, err) + assert.NotNil(t, res) + + // Login should fail as email is not verified + loginRes, err := resolvers.LoginResolver(ctx, model.LoginInput{ + Email: email, + Password: s.TestInfo.Password, + }) + assert.Error(t, err) + assert.Nil(t, loginRes) + verificationRequest, err := db.Provider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeBasicAuthSignup) + assert.Nil(t, err) + assert.Equal(t, email, verificationRequest.Email) + verifyRes, err := resolvers.VerifyEmailResolver(ctx, model.VerifyEmailInput{ + Token: verificationRequest.Token, + }) + assert.Nil(t, err) + assert.NotEqual(t, verifyRes.AccessToken, "", "access token should not be empty") + + // Using access token update profile + s.GinContext.Request.Header.Set("Authorization", "Bearer "+refs.StringValue(verifyRes.AccessToken)) + ctx = context.WithValue(req.Context(), "GinContextKey", s.GinContext) + updateProfileRes, err := resolvers.UpdateProfileResolver(ctx, model.UpdateProfileInput{ + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + assert.NoError(t, err) + assert.NotEmpty(t, updateProfileRes.Message) + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableTOTPLogin, false) + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableMailOTPLogin, true) + + // Login should not return error but access token should be empty + loginRes, err = resolvers.LoginResolver(ctx, model.LoginInput{ + Email: email, + Password: s.TestInfo.Password, + }) + assert.NoError(t, err) + assert.NotNil(t, loginRes) + assert.NotNil(t, loginRes.TotpBase64URL) + assert.NotNil(t, loginRes.TotpToken) + assert.Nil(t, loginRes.AccessToken) + assert.Equal(t, loginRes.Message, `Proceed to totp screen`) + + // get totp url for validation + pngBytes, err := base64.StdEncoding.DecodeString(*loginRes.TotpBase64URL) + assert.NoError(t, err) + qrmatrix, err := qrcode.Decode(bytes.NewReader(pngBytes)) + assert.NoError(t, err) + tf, label, err := twofactor.FromURL(qrmatrix.Content) + data := strings.Split(label, ":") + assert.NoError(t, err) + assert.Equal(t, email, data[1]) + assert.NotNil(t, tf) + + code := tf.OTP() + + assert.NotEmpty(t, code) + + valid, err := resolvers.VerifyTotpResolver(ctx, model.VerifyTOTPRequest{ + Otp: code, + Token: *loginRes.TotpToken, + }) + + accessToken := *valid.AccessToken + assert.NoError(t, err) + assert.NotNil(t, accessToken) + assert.Equal(t, `Logged in successfully`, valid.Message) + + assert.NotEmpty(t, accessToken) + claims, err := token.ParseJWTToken(accessToken) + assert.NoError(t, err) + assert.NotEmpty(t, claims) + loginMethod := claims["login_method"] + sessionKey := verifyRes.User.ID + if loginMethod != nil && loginMethod != "" { + sessionKey = loginMethod.(string) + ":" + verifyRes.User.ID + } + + sessionToken, err := memorystore.Provider.GetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+claims["nonce"].(string)) + assert.NoError(t, err) + assert.NotEmpty(t, sessionToken) + + cookie := fmt.Sprintf("%s=%s;", constants.AppCookieName+"_session", sessionToken) + cookie = strings.TrimSuffix(cookie, ";") + + req.Header.Set("Cookie", cookie) + //logged out + logout, err := resolvers.LogoutResolver(ctx) + assert.NoError(t, err) + assert.Equal(t, logout.Message, `Logged out successfully`) + + loginRes, err = resolvers.LoginResolver(ctx, model.LoginInput{ + Email: email, + Password: s.TestInfo.Password, + }) + assert.NoError(t, err) + assert.NotNil(t, loginRes) + assert.NotNil(t, loginRes.TotpToken) + assert.Nil(t, loginRes.TotpBase64URL) + assert.Nil(t, loginRes.AccessToken) + assert.Equal(t, loginRes.Message, `Proceed to totp screen`) + + code = tf.OTP() + assert.NotEmpty(t, code) + + valid, err = resolvers.VerifyTotpResolver(ctx, model.VerifyTOTPRequest{ + Otp: code, + Token: *loginRes.TotpToken, + }) + assert.NoError(t, err) + assert.NotNil(t, *valid.AccessToken) + assert.Equal(t, `Logged in successfully`, valid.Message) + + cleanData(email) + }) +}