diff --git a/model/token.go b/model/token.go index a53fbd43..69030a73 100644 --- a/model/token.go +++ b/model/token.go @@ -9,13 +9,18 @@ import ( type TokenType string const ( - TokenTypeInvite TokenType = "invite" // TokenTypeInvite is an invite token type value. - TokenTypeReset TokenType = "reset" // TokenTypeReset is an reset token type value. - TokenTypeWebCookie TokenType = "web-cookie" // TokenTypeWebCookie is a web-cookie token type value. - TokenTypeAccess TokenType = "access" // TokenTypeAccess is an access token type. - TokenTypeRefresh TokenType = "refresh" // TokenTypeRefresh is a refresh token type. + TokenTypeInvite TokenType = "invite" // TokenTypeInvite is an invite token type value. + TokenTypeReset TokenType = "reset" // TokenTypeReset is an reset token type value. + TokenTypeWebCookie TokenType = "web-cookie" // TokenTypeWebCookie is a web-cookie token type value. + TokenTypeAccess TokenType = "access" // TokenTypeAccess is an access token type. + TokenTypeRefresh TokenType = "refresh" // TokenTypeRefresh is a refresh token type. + TokenTypeManagement TokenType = "management" // TokenTypeManagement is a management token type for admin panel." + TokenTypeID TokenType = "id_token" // id token type regarding oidc specification + TokenTypeSignin TokenType = "signin" // signin token issues for user to sign in, etc to exchange for auth tokens. For example from admin panel I can send a link to user to siging to email with magic link. + TokenTypeActor TokenType = "actor" // actor token is token impersonation. Admin could impersonated to be some of the users. // TODO: Deprecate it? + // ! Deprecated: don't use it. TokenTypeTFAPreauth TokenType = "2fa-preauth" // TokenTypeTFAPreauth is an 2fa preauth token type. // TODO: Add other tokens, like admin, one, 2fa etc ) diff --git a/model/token_service.go b/model/token_service.go index fe859f19..5abb6a2d 100644 --- a/model/token_service.go +++ b/model/token_service.go @@ -1,20 +1,21 @@ package model -const ( - // OfflineScope is a scope value to request refresh token. - OfflineScope = "offline" -) - // TODO: refactor to reduce number of methods +// TODO: implement key rotation // TokenService is an abstract token manager. type TokenService interface { - NewAccessToken(u User, scopes []string, app AppData, requireTFA bool, tokenPayload map[string]interface{}) (Token, error) - NewRefreshToken(u User, scopes []string, app AppData) (Token, error) - RefreshAccessToken(token Token) (Token, error) - NewInviteToken(email, role, audience string, data map[string]interface{}) (Token, error) - NewResetToken(userID string) (Token, error) - NewToken(tokenType TokenType, userID string, payload []any) (Token, error) - NewWebCookieToken(u User) (Token, error) + // new methods + NewToken(tokenType TokenType, u User, fields []string, payload map[string]any) (Token, error) + SignToken(token Token) (string, error) + + // // old methods + // NewAccessToken(u User, scopes []string, app AppData, requireTFA bool, tokenPayload map[string]interface{}) (Token, error) + // NewRefreshToken(u User, scopes []string, app AppData) (Token, error) + // RefreshAccessToken(token Token) (Token, error) + // NewInviteToken(email, role, audience string, data map[string]interface{}) (Token, error) + // NewResetToken(userID string) (Token, error) + // // NewToken(tokenType TokenType, userID string, payload []any) (Token, error) + // NewWebCookieToken(u User) (Token, error) Parse(string) (Token, error) String(Token) (string, error) Issuer() string @@ -25,6 +26,7 @@ type TokenService interface { // replace the old private key with a new one SetPrivateKey(key interface{}) PrivateKey() interface{} + // not using crypto.PublicKey here to avoid dependencies PublicKey() interface{} KeyID() string diff --git a/model/user.go b/model/user.go index 57f2ebec..e54451e0 100644 --- a/model/user.go +++ b/model/user.go @@ -67,14 +67,10 @@ type VerificationDetails struct { // UserData model represents all collective information about the user type UserData struct { - UserID string `json:"user_id,omitempty"` - TenantMembership *struct { - TenantID string `json:"tenant_id,omitempty"` - TenantName string `json:"tenant_name,omitempty"` - Groups map[string]string `json:"groups,omitempty"` // map of group names to ids - } `json:"tenant_membership,omitempty"` - AuthEnrollments []UserAuthEnrolment `json:"auth_enrollments,omitempty"` - Identities []UnitedUserIdentity `json:"identities,omitempty"` + UserID string `json:"user_id,omitempty"` + TenantMembership []TenantMembership `json:"tenant_membership,omitempty"` + AuthEnrollments []UserAuthEnrolment `json:"auth_enrollments,omitempty"` + Identities []UnitedUserIdentity `json:"identities,omitempty"` // User devices ActiveDevices []UserDevice `json:"active_devices,omitempty"` @@ -85,6 +81,13 @@ type UserData struct { DebugOTPCode string `json:"debug_otp,omitempty"` } +// UserAuthEnrolment is representation for user tenant membership +type TenantMembership struct { + TenantID string `json:"tenant_id,omitempty"` + TenantName string `json:"tenant_name,omitempty"` + Groups map[string]string `json:"groups,omitempty"` // map of group names to ids +} + type UserBlockedDetails struct { Reason string `json:"reason,omitempty"` BlockedAt time.Time `json:"blocked_at,omitempty"` diff --git a/model/user_auth.go b/model/user_auth.go new file mode 100644 index 00000000..22dc83c8 --- /dev/null +++ b/model/user_auth.go @@ -0,0 +1,10 @@ +package model + +// login response for user +type AuthResponse struct { + IDToken *string `json:"id_token,omitempty"` + AccessToken *string `json:"access_token,omitempty"` + RefreshToken *string `json:"refresh_token,omitempty"` + RedirectURI *string `json:"redirect_uri,omitempty"` + ClientChallenge *string `json:"client_challenge,omitempty"` +} diff --git a/model/user_auth_challenge.go b/model/user_auth_challenge.go index e4a813ca..9b322333 100644 --- a/model/user_auth_challenge.go +++ b/model/user_auth_challenge.go @@ -41,6 +41,7 @@ type UserAuthChallenge struct { SolvedUserAgent string `json:"solved_user_agent"` SolvedDeviceID string `json:"solved_device_id"` ExpiresMins int `json:"expires_mins"` + ScopesRequested []string `json:"scopes_requested"` OTP string `json:"value"` // OTP value, it the challenge is OTP code } diff --git a/model/user_controller.go b/model/user_controller.go index 347b5686..085acba1 100644 --- a/model/user_controller.go +++ b/model/user_controller.go @@ -36,3 +36,5 @@ type ChallengeController interface { VerifyChallenge(ctx context.Context, challenge UserAuthChallenge, userIDValue string) error LoginOrRegisterUserWithChallenge(ctx context.Context, challenge UserAuthChallenge, userIDValue string) (User, error) } + + diff --git a/model/user_fieldset.go b/model/user_fieldset.go index 74c0c22e..49115557 100644 --- a/model/user_fieldset.go +++ b/model/user_fieldset.go @@ -9,6 +9,12 @@ const ( UserFieldsetPassword UserFieldset = "password" UserFieldsetSecondaryIdentity UserFieldset = "secondary_identity" UserFieldsetUpdatableByUser UserFieldset = "updatable_by_user" + UserFieldsetScopeOIDC UserFieldset = OIDCScope + UserFieldsetScopeEmail UserFieldset = EmailScope + UserFieldsetScopePhone UserFieldset = PhoneScope + UserFieldsetScopeProfile UserFieldset = ProfileScope + UserFieldsetScopeAddress UserFieldset = AddressScope + // TODO: Add fieldset cases for other cases. UserFieldEmail = "Email" @@ -61,6 +67,34 @@ var UserFieldsetMap = map[UserFieldset][]string{ "Username", "PhoneNumber", }, + UserFieldsetScopeOIDC: { + "Profile", + "Picture", + "Website", + "Gender", + "Birthday", + "Timezone", + "Locale", + }, + UserFieldsetScopeEmail: { + "Email", + "EmailVerificationDetails", + }, + UserFieldsetScopePhone: { + "PhoneNumber", + "PhoneVerificationDetails", + }, + UserFieldsetScopeProfile: { + "Username", + "GivenName", + "FamilyName", + "MiddleName", + "Nickname", + "PreferredUsername", + }, + UserFieldsetScopeAddress: { + "Address", + }, } func (f UserFieldset) Fields() []string { diff --git a/model/user_scopes.go b/model/user_scopes.go new file mode 100644 index 00000000..b018dbd2 --- /dev/null +++ b/model/user_scopes.go @@ -0,0 +1,51 @@ +package model + +// we support OIDC scopes, not claims +// https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims +const ( + // OfflineScope is a scope value to request refresh token. + OfflineScope = "offline" + OIDCScope = "openid" + EmailScope = "email" + PhoneScope = "phone" + ProfileScope = "profile" + AddressScope = "address" + CustomScopePrefix = "custom:" + IDTokenScope = "id" + TenantScope = "tenant" + AccessTokenScopePrefix = "access:" + TenantScopePrefix = "tenant:" // tenant:123 - request tenant data only for tenant 123 + TenantScopeAll = "all" // "tenant:all" - return all scopes for all ten +) + +func FieldsetForScopes(scopes []string) []string { + fieldset := []string{} + for _, scope := range scopes { + switch scope { + case OIDCScope: + fieldset = append(fieldset, UserFieldsetMap[UserFieldsetScopeOIDC]...) + case EmailScope: + fieldset = append(fieldset, UserFieldsetMap[UserFieldsetScopeEmail]...) + case PhoneScope: + fieldset = append(fieldset, UserFieldsetMap[UserFieldsetScopePhone]...) + case AddressScope: + fieldset = append(fieldset, UserFieldsetMap[UserFieldsetScopeAddress]...) + case ProfileScope: + fieldset = append(fieldset, UserFieldsetMap[UserFieldsetScopeProfile]...) + } + } + return removeDuplicate(fieldset) +} + +// removeDuplicate +func removeDuplicate[T comparable](slice []T) []T { + allKeys := make(map[T]bool) + list := []T{} + for _, item := range slice { + if _, ok := allKeys[item]; !ok { + allKeys[item] = true + list = append(list, item) + } + } + return list +} diff --git a/server/controller/user_controller_challenges.go b/server/controller/user_controller_challenges.go index 7bfa20da..041e476c 100644 --- a/server/controller/user_controller_challenges.go +++ b/server/controller/user_controller_challenges.go @@ -237,17 +237,17 @@ func randomOTP(length int) string { } // VerifyChallenge verifies challenge from user -func (c *UserStorageController) VerifyChallenge(ctx context.Context, challenge model.UserAuthChallenge, userIDValue string) (model.User, error) { +func (c *UserStorageController) VerifyChallenge(ctx context.Context, challenge model.UserAuthChallenge, userIDValue string) (model.User, model.AppData, error) { app, err := c.as.AppByID(challenge.AppID) if err != nil { - return model.User{}, err + return model.User{}, model.AppData{}, err } appAuthStrategies := app.AuthStrategies compatibleStrategies := model.FilterCompatible(challenge.Strategy, appAuthStrategies) // the app does not supports that type of challenge if len(compatibleStrategies) == 0 { - return model.User{}, l.LocalizedError{ErrID: l.ErrorRequestChallengeUnsupportedByAPP} + return model.User{}, model.AppData{}, l.LocalizedError{ErrID: l.ErrorRequestChallengeUnsupportedByAPP} } // selecting the first strategy from the list. @@ -257,16 +257,21 @@ func (c *UserStorageController) VerifyChallenge(ctx context.Context, challenge m // using the challenge he requested // if no user found, just silently return with no error for security reason u, err := c.UserByAuthStrategy(ctx, auth, userIDValue) - if err != nil { - return model.User{}, nil + + // check if we can register passwordless users in the app, if so, let's send a code + if err != nil && errors.Is(err, l.ErrorUserNotFound) && !app.RegistrationForbidden && app.PasswordlessRegistrationAllowed { + u = ephemeralUserForStrategy(challenge.Strategy, userIDValue) + } else if err != nil { + return model.User{}, model.AppData{}, err } // check if user has debug challenge and app allows to use it and it matches the code in request + // ? does not works for new users, to register you need to use real code (or not?) shouldValidateOTP := true - if app.DebugOTPCodeAllowed { + if app.DebugOTPCodeAllowed && !model.ID(u.ID).IsNewUserID() { ud, err := c.u.UserData(ctx, u.ID, model.UserDataFieldDebugOTPCode) if err != nil { - return model.User{}, err + return model.User{}, model.AppData{}, err } if len(ud.DebugOTPCode) > 0 && ud.DebugOTPCode == challenge.OTP { shouldValidateOTP = false @@ -278,31 +283,52 @@ func (c *UserStorageController) VerifyChallenge(ctx context.Context, challenge m if shouldValidateOTP { ch, err := c.uas.GetLatestChallenge(ctx, challenge.Strategy, u.ID) if err != nil { - return model.User{}, err + return model.User{}, model.AppData{}, err } err = ch.Valid() if err != nil { // TODO: Login attempt to login with invalid code - return model.User{}, err + return model.User{}, model.AppData{}, err } if ch.OTP != challenge.OTP { // TODO: Login attempt to login with invalid code - return model.User{}, l.LocalizedError{ErrID: l.ErrorOtpIncorrect} + return model.User{}, model.AppData{}, l.LocalizedError{ErrID: l.ErrorOtpIncorrect} } // add information about context, when the challenge been solved ch.SolvedDeviceID = challenge.DeviceID ch.SolvedUserAgent = challenge.UserAgent c.uas.MarkChallengeAsSolved(ctx, ch) } - return u, nil + return u, app, nil } // Passwordless login or register user with challenge func (c *UserStorageController) LoginOrRegisterUserWithChallenge(ctx context.Context, challenge model.UserAuthChallenge, userIDValue string) (model.User, error) { + // guard check if challenge.Strategy.Type() != model.AuthStrategyFirstFactorInternal { return model.User{}, l.LocalizedError{ErrID: l.ErrorLoginTypeNotSupported} } + u, _, err := c.VerifyChallenge(ctx, challenge, userIDValue) + if err != nil { + return model.User{}, err + } + + // let's register the user, if it is new user + if model.ID(u.ID).IsNewUserID() { + var err error + u.ID = "" // clear ID, so database layer should generate new one + u, err = c.ums.AddUser(ctx, u) + if err != nil { + return model.User{}, err + } + } + + // TODO: save successful login attempt to the database + // TODO: update active devices list + + // c.loginFlow(ctx, app, u, challenge.ScopesRequested) // ?? requested scopers + // check if we have no user exists and the code is valid and app allows to register new passwordless users // then we create one return model.User{}, nil diff --git a/server/controller/user_controller_communication.go b/server/controller/user_controller_communication.go index 66cccb8d..925e614d 100644 --- a/server/controller/user_controller_communication.go +++ b/server/controller/user_controller_communication.go @@ -36,7 +36,7 @@ func (c *UserStorageController) SendPasswordResetEmail(ctx context.Context, user return model.ResetEmailData{}, err } - resetToken, err := c.ts.NewResetToken(user.ID) + resetToken, err := c.ts.NewToken(model.TokenTypeReset, user, nil, nil) if err != nil { return model.ResetEmailData{}, err } diff --git a/server/controller/user_controller_login.go b/server/controller/user_controller_login.go new file mode 100644 index 00000000..e83b44d6 --- /dev/null +++ b/server/controller/user_controller_login.go @@ -0,0 +1,92 @@ +package controller + +import ( + "context" + "strings" + + "github.com/madappgang/identifo/v2/model" +) + +// TODO! we need to add tenant related information flattered, as: +// "112233:admin:user", where 112233 - tenant ID, admin - a group, user - role in a group +func (c *UserStorageController) getJWTTokens(ctx context.Context, app model.AppData, u model.User, scopes []string) (model.AuthResponse, error) { + // check if we are + + // TODO: implement custom payload provider for app + resp := model.AuthResponse{} + ap := AccessTokenScopes(scopes) // fields for access token + apf := model.FieldsetForScopes(scopes) + + at, err := c.ts.NewToken(model.TokenTypeAccess, u, apf, nil) + if err != nil { + return resp, err + } + access, err := c.ts.SignToken(at) + if err != nil { + return resp, err + } + + // id token + var id string + if sliceContains(scopes, model.IDTokenScope) { + // get fields for id token + f := model.FieldsetForScopes(scopes) + data := map[string]any{} + + idt, err := c.ts.NewToken(model.TokenTypeID, u, f, data) + if err != nil { + return resp, err + } + id, err = c.ts.SignToken(idt) + if err != nil { + return resp, err + } + } + + // refresh token + var refresh string + if sliceContains(scopes, model.OfflineScope) && app.Offline { + rt, err := c.ts.NewToken(model.TokenTypeRefresh, u, ap, nil) + if err != nil { + return resp, err + } + refresh, err = c.ts.SignToken(rt) + if err != nil { + return resp, err + } + } + + resp.AccessToken = &access + if len(id) > 0 { + resp.IDToken = &id + } + if len(refresh) > 0 { + // TODO: attach refresh token to device + // TODO: save refresh token to db to invalidate on logout or device deactivation + resp.RefreshToken = &refresh + } + + return resp, nil +} + +func AccessTokenScopes(scopes []string) []string { + result := []string{} + for _, s := range scopes { + if strings.HasPrefix(s, model.AccessTokenScopePrefix) && len(s) > len(model.AccessTokenScopePrefix) { + result = append(result, s[len(model.AccessTokenScopePrefix):]) + } + } + return result +} + +func TenantData(ud model.UserData) map[string]any { + res := map[string]any{} + for _, t := range ud.TenantMembership { + tid := t.TenantID + for k, v := range t.Groups { + // "tenant_id:group_id" : "role" + res[tid+":"+k] = v + } + } + return res +} diff --git a/server/controller/user_controller_login_test.go b/server/controller/user_controller_login_test.go new file mode 100644 index 00000000..312bae29 --- /dev/null +++ b/server/controller/user_controller_login_test.go @@ -0,0 +1,50 @@ +package controller_test + +import ( + "testing" + + "github.com/madappgang/identifo/v2/model" + "github.com/madappgang/identifo/v2/server/controller" + "github.com/stretchr/testify/assert" +) + +func TestAddTenantData(t *testing.T) { + ud := model.UserData{ + TenantMembership: []model.TenantMembership{ + { + TenantID: "tenant1", + Groups: map[string]string{"default": "admin", "group1": "user"}, + }, + { + TenantID: "tenant2", + Groups: map[string]string{"default": "guest", "group33": "admin"}, + }, + }, + } + + flattenData := controller.TenantData(ud) + assert.Contains(t, flattenData, "tenant1:default") + assert.Contains(t, flattenData, "tenant1:group1") + assert.Contains(t, flattenData, "tenant2:default") + assert.Contains(t, flattenData, "tenant2:group33") + assert.Equal(t, flattenData["tenant1:default"], "admin") + assert.Equal(t, flattenData["tenant1:group1"], "user") + assert.Equal(t, flattenData["tenant2:default"], "guest") + assert.Equal(t, flattenData["tenant2:group33"], "admin") + assert.Len(t, flattenData, 4) +} + +func TestAccessTokenScopes(t *testing.T) { + scopes := []string{ + "id", + "offline", + "access:", + "access:", + "access:profile", + "access:oidc", + } + r := controller.AccessTokenScopes(scopes) + assert.Len(t, r, 2) + assert.Contains(t, r, "profile") + assert.Contains(t, r, "oidc") +} diff --git a/web/api/auth_challenges.go b/web/api/auth_challenges.go index 24235156..010cbc5e 100644 --- a/web/api/auth_challenges.go +++ b/web/api/auth_challenges.go @@ -68,6 +68,7 @@ func (ar *Router) RequestChallenge() http.HandlerFunc { DeviceID: d.Device, UserAgent: agent, CreatedAt: time.Now(), + ScopesRequested: d.Scopes, UserCodeChallenge: d.ClientCodeChallenge, Strategy: model.FirstFactorInternalStrategy{ Identity: idType, diff --git a/web/api/login.go b/web/api/login.go index ea750298..eb03a93f 100644 --- a/web/api/login.go +++ b/web/api/login.go @@ -1,118 +1,14 @@ package api import ( - "errors" - "fmt" "net/http" - "time" "github.com/madappgang/identifo/v2/l" "github.com/madappgang/identifo/v2/model" - thp "github.com/madappgang/identifo/v2/user_payload_provider/http" "github.com/madappgang/identifo/v2/web/authorization" "github.com/madappgang/identifo/v2/web/middleware" - "github.com/xlzd/gotp" ) -var ( - errPleaseEnableTFA = fmt.Errorf("please enable two-factor authentication to be able to use this app") - errPleaseSetPhoneTFA = fmt.Errorf("please set phone for two-factor authentication to be able to use this app") - errPleaseSetEmailTFA = fmt.Errorf("please set email for two-factor authentication to be able to use this app") - errPleaseDisableTFA = fmt.Errorf("please disable two-factor authentication to be able to use this app") -) - -type SendTFAEmailData struct { - User model.User - OTP string - Data interface{} -} - -const ( - smsTFACode = "%v is your one-time password!" - hotpLifespanHours = 12 // One time code expiration in hours, default value is 30 secs for TOTP and 12 hours for HOTP -) - -// AuthResponse is a response with successful auth data. -type AuthResponse struct { - AccessToken string `json:"access_token,omitempty" bson:"access_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty" bson:"refresh_token,omitempty"` - User model.User `json:"user,omitempty" bson:"user,omitempty"` - Require2FA bool `json:"require_2fa" bson:"require_2fa"` - Enabled2FA bool `json:"enabled_2fa" bson:"enabled_2fa"` - CallbackUrl string `json:"callback_url,omitempty" bson:"callback_url,omitempty"` - Scopes []string `json:"scopes,omitempty" bson:"scopes,omitempty"` -} - -type login struct { - Email string `json:"email,omitempty"` - Username string `json:"username,omitempty"` - Phone string `json:"phone,omitempty"` -} - -type loginData struct { - login - Password string `json:"password,omitempty"` - DeviceToken string `json:"device_token,omitempty"` - Scopes []string `json:"scopes,omitempty"` -} - -func (ld *login) validate() error { - emailLen := len(ld.Email) - phoneLen := len(ld.Phone) - usernameLen := len(ld.Username) - if emailLen > 0 { - if phoneLen > 0 || usernameLen > 0 { - return fmt.Errorf("don't use phone or username when login with email") - } - if !model.EmailRegexp.MatchString(ld.Email) { - return fmt.Errorf("invalid email") - } - } - if phoneLen > 0 { - if emailLen > 0 || usernameLen > 0 { - return fmt.Errorf("don't use email or username when login with phone") - } - if !model.PhoneRegexp.MatchString(ld.Email) { - return fmt.Errorf("invalid phone") - } - } - if usernameLen > 0 { - if phoneLen > 0 || emailLen > 0 { - return fmt.Errorf("don't use phone or email when login with username") - } - if usernameLen < 6 || usernameLen > 130 { - return fmt.Errorf("incorrect username length %d, expected a number between 6 and 130", usernameLen) - } - } - return nil -} - -func (ld *loginData) validate() error { - if err := ld.login.validate(); err != nil { - return err - } - pswdLen := len(ld.Password) - if pswdLen < 6 || pswdLen > 50 { - return fmt.Errorf("incorrect password length %d, expected a number between 6 and 130", pswdLen) - } - return nil -} - -func (ar *Router) checkSupportedWays(l login) error { - if !ar.SupportedLoginWays.Email && len(l.Email) > 0 { - return fmt.Errorf("application does not support login with email") - } - - if !ar.SupportedLoginWays.Phone && len(l.Phone) > 0 { - return fmt.Errorf("application does not support login with phone") - } - - if !ar.SupportedLoginWays.Username && len(l.Username) > 0 { - return fmt.Errorf("application does not support login with username") - } - return nil -} - // LoginWithPassword logs user in with email and password. func (ar *Router) LoginWithPassword() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -183,31 +79,6 @@ func (ar *Router) LoginWithPassword() http.HandlerFunc { } } -func (ar *Router) sendOTPCode(app model.AppData, user model.User) error { - // we don't need to send any code for FTA Type App, it uses TOTP and generated on client side with the app - if ar.tfaType != model.TFATypeApp { - - // increment hotp code seed - otp := gotp.NewDefaultHOTP(user.TFAInfo.Secret).At(user.TFAInfo.HOTPCounter + 1) - tfa := user.TFAInfo - tfa.HOTPCounter++ - tfa.HOTPExpiredAt = time.Now().Add(time.Hour * hotpLifespanHours) - user.TFAInfo = tfa - if _, err := ar.server.Storages().User.UpdateUser(user.ID, user); err != nil { - return err - } - switch ar.tfaType { - case model.TFATypeSMS: - return ar.sendTFACodeInSMS(app, user.TFAInfo.Phone, otp) - case model.TFATypeEmail: - return ar.sendTFACodeOnEmail(app, user, otp) - } - - } - - return nil -} - // IsLoggedIn is for checking whether user is logged in or not. // In fact, all needed work is done in Token middleware. // If we reached this code, user is logged in (presented valid and not blacklisted access token). @@ -224,7 +95,7 @@ func (ar *Router) GetUser() http.HandlerFunc { locale := r.Header.Get("Accept-Language") userID := tokenFromContext(r.Context()).UserID() - user, err := ar.server.Storages().User.UserByID(userID) + user, err := ar.server.Storages().User.UserByID(r.Context(), userID) if err != nil { ar.Error(w, locale, http.StatusUnauthorized, l.ErrorStorageFindUserIDError, userID, err) return @@ -232,106 +103,3 @@ func (ar *Router) GetUser() http.HandlerFunc { ar.ServeJSON(w, locale, http.StatusOK, user.SanitizedTFA()) } } - -// getTokenPayloadForApp get additional token payload data -func (ar *Router) getTokenPayloadForApp(app model.AppData, user model.User) (map[string]interface{}, error) { - if app.TokenPayloadService == model.TokenPayloadServiceHttp { - // check if we have service cached - ps, exists := ar.tokenPayloadServices[app.ID] - if !exists { - var err error - ps, err = thp.NewTokenPayloadProvider( - app.TokenPayloadServiceHttpSettings.Secret, - app.TokenPayloadServiceHttpSettings.URL, - ) - if err != nil { - return nil, err - } - ar.tokenPayloadServices[app.ID] = ps - } - return ps.TokenPayloadForApp(app.ID, app.Name, user.ID) - } - return nil, nil -} - -// loginUser creates and returns access token for a user. -// createRefreshToken boolean param tells if we should issue refresh token as well. -func (ar *Router) loginUser(user model.User, scopes []string, app model.AppData, createRefreshToken, require2FA bool, tokenPayload map[string]interface{}) (string, string, error) { - token, err := ar.server.Services().Token.NewAccessToken(user, scopes, app, require2FA, tokenPayload) - if err != nil { - return "", "", err - } - - accessTokenString, err := ar.server.Services().Token.String(token) - if err != nil { - return "", "", err - } - if !createRefreshToken || require2FA { - return accessTokenString, "", nil - } - - refresh, err := ar.server.Services().Token.NewRefreshToken(user, scopes, app) - if err != nil { - ar.Logger.Println(err) - return accessTokenString, "", nil - } - refreshTokenString, err := ar.server.Services().Token.String(refresh) - if err != nil { - return "", "", err - } - return accessTokenString, refreshTokenString, nil -} - -func (ar *Router) loginFlow(app model.AppData, user model.User, requestedScopes []string) (AuthResponse, error) { - // check if the user has the scope, that allows to login to the app - // user has to have at least one scope app expecting - if len(app.Scopes) > 0 && len(model.SliceIntersect(app.Scopes, user.Scopes)) == 0 { - return AuthResponse{}, errors.New("user does not have required scope for the app") - } - - // Do login flow. - scopes := []string{} - // if we requested any scope, let's provide all the scopes user has and requested - if len(requestedScopes) > 0 { - scopes = model.SliceIntersect(requestedScopes, user.Scopes) - } - if model.SliceContains(requestedScopes, "offline") && app.Offline { - scopes = append(scopes, "offline") - } - - // Check if we should require user to authenticate with 2FA. - require2FA, enabled2FA, err := ar.check2FA(app.TFAStatus, ar.tfaType, user) - if !require2FA && enabled2FA && err != nil { - return AuthResponse{}, err - } - - offline := contains(scopes, model.OfflineScope) - tokenPayload, err := ar.getTokenPayloadForApp(app, user) - if err != nil { - return AuthResponse{}, err - } - - accessToken, refreshToken, err := ar.loginUser(user, scopes, app, offline, require2FA, tokenPayload) - if err != nil { - return AuthResponse{}, err - } - - result := AuthResponse{ - AccessToken: accessToken, - RefreshToken: refreshToken, - Require2FA: require2FA, - Enabled2FA: enabled2FA, - } - - if require2FA && enabled2FA { - if err := ar.sendOTPCode(app, user); err != nil { - return AuthResponse{}, err - } - } else { - ar.server.Storages().User.UpdateLoginMetadata(user.ID) - } - - user = user.Sanitized() - result.User = user - return result, nil -}