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/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/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/go.mod b/server/go.mod index 8404573..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 @@ -26,6 +27,7 @@ require ( 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 4b61a81..467607b 100644 --- a/server/go.sum +++ b/server/go.sum @@ -56,6 +56,8 @@ 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= @@ -131,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= @@ -256,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= @@ -323,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= @@ -747,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 a7b3209..7217bc5 100644 --- a/server/graph/generated/generated.go +++ b/server/graph/generated/generated.go @@ -272,6 +272,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 } @@ -1900,6 +1901,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 @@ -2317,6 +2325,7 @@ type User { revoked_timestamp: Int64 is_multi_factor_auth_enabled: Boolean app_data: Map + totp_verified: Boolean } type Users { @@ -4013,6 +4022,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) }, @@ -7524,6 +7535,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) }, @@ -9357,6 +9370,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) }, @@ -10669,6 +10684,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) }, @@ -10938,6 +10955,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) }, @@ -12846,6 +12865,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 { @@ -12979,6 +13039,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) }, @@ -13194,6 +13256,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) }, @@ -20329,6 +20393,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)) } diff --git a/server/graph/model/models_gen.go b/server/graph/model/models_gen.go index 8037700..c5586a8 100644 --- a/server/graph/model/models_gen.go +++ b/server/graph/model/models_gen.go @@ -455,6 +455,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 { diff --git a/server/graph/schema.graphqls b/server/graph/schema.graphqls index 9fe68ba..7805861 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 { 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/revoke_access_test.go b/server/test/revoke_access_test.go index 4be042d..2f83d3e 100644 --- a/server/test/revoke_access_test.go +++ b/server/test/revoke_access_test.go @@ -28,6 +28,8 @@ func revokeAccessTest(t *testing.T, s TestSetup) { verifyRes, err := resolvers.VerifyEmailResolver(ctx, model.VerifyEmailInput{ Token: verificationRequest.Token, }) + fmt.Println("\n", verifyRes) + fmt.Println("\n", err) assert.NoError(t, err) assert.NotNil(t, verifyRes.AccessToken) diff --git a/server/test/verification_requests_test.go b/server/test/verification_requests_test.go index e5d5d73..11b7596 100644 --- a/server/test/verification_requests_test.go +++ b/server/test/verification_requests_test.go @@ -23,6 +23,8 @@ func verificationRequestsTest(t *testing.T, s TestSetup) { Password: s.TestInfo.Password, ConfirmPassword: s.TestInfo.Password, }) + fmt.Println("res", res) + fmt.Println("err", err) assert.NoError(t, err) assert.NotNil(t, res) limit := int64(10) 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 index 104a4b0..c4f38d2 100644 --- a/server/test/verify_totp_test.go +++ b/server/test/verify_totp_test.go @@ -1,18 +1,30 @@ package test import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "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" + "github.com/gokyle/twofactor" "github.com/stretchr/testify/assert" + "github.com/tuotoo/qrcode" + "strings" "testing" ) func verifyTOTPTest(t *testing.T, s TestSetup) { t.Helper() t.Run(`should verify totp`, func(t *testing.T) { - _, ctx := createContext(s) - email := "verify_otp." + s.TestInfo.Email + 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, @@ -28,54 +40,108 @@ func verifyTOTPTest(t *testing.T, s TestSetup) { }) assert.Error(t, err) assert.Nil(t, loginRes) - _, err = db.Provider.GenerateTotp(ctx, loginRes.User.ID) + verificationRequest, err := db.Provider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeBasicAuthSignup) assert.Nil(t, err) - //assert.Equal(t, ??, string) - // 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) - // - // // Login should not return error but access token should be empty as otp should have been sent - // loginRes, err = resolvers.LoginResolver(ctx, model.LoginInput{ - // Email: email, - // Password: s.TestInfo.Password, - // }) - // assert.NoError(t, err) - // assert.NotNil(t, loginRes) - // assert.Nil(t, loginRes.AccessToken) - // - // // Get otp from db - // otp, err := db.Provider.GetOTPByEmail(ctx, email) - // assert.NoError(t, err) - // assert.NotEmpty(t, otp.Otp) - // // Get user by email - // user, err := db.Provider.GetUserByEmail(ctx, email) - // assert.NoError(t, err) - // assert.NotNil(t, user) - // // Set mfa cookie session - // mfaSession := uuid.NewString() - // memorystore.Provider.SetMfaSession(user.ID, mfaSession, time.Now().Add(1*time.Minute).Unix()) - // cookie := fmt.Sprintf("%s=%s;", constants.MfaCookieName+"_session", mfaSession) - // cookie = strings.TrimSuffix(cookie, ";") - // req.Header.Set("Cookie", cookie) - // verifyOtpRes, err := resolvers.VerifyOtpResolver(ctx, model.VerifyOTPRequest{ - // Email: &email, - // Otp: otp.Otp, - // }) - // assert.Nil(t, err) - // assert.NotEqual(t, verifyOtpRes.AccessToken, "", "access token should not be empty") - // cleanData(email) - //}) + 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.TokenTotp) + 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.TokenTotp, + }) + + 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.TokenTotp) + 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.TokenTotp, + }) + assert.NoError(t, err) + assert.NotNil(t, *valid.AccessToken) + assert.Equal(t, `Logged in successfully`, valid.Message) + + cleanData(email) }) }