Skip to content

Commit

Permalink
Add separate 2fa phone/email and enable tfa on verify
Browse files Browse the repository at this point in the history
  • Loading branch information
sokolovstas committed Feb 3, 2022
1 parent 83b6a75 commit 99db444
Show file tree
Hide file tree
Showing 12 changed files with 153 additions and 118 deletions.
2 changes: 2 additions & 0 deletions model/user_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ type TFAInfo struct {
IsEnabled bool `json:"is_enabled" bson:"is_enabled"`
HOTPCounter int `json:"hotp_counter" bson:"hotp_counter"`
HOTPExpiredAt time.Time `json:"hotp_expired_at" bson:"hotp_expired_at"`
Email string `json:"email" bson:"email"`
Phone string `json:"phone" bson:"phone"`
Secret string `json:"secret" bson:"secret"`
}

Expand Down
143 changes: 124 additions & 19 deletions web/api/2fa.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,23 @@ type ResetEmailData struct {

// EnableTFA enables two-factor authentication for the user.
func (ar *Router) EnableTFA() http.HandlerFunc {
type requestBody struct {
Email string `json:"email"`
Phone string `json:"phone"`
}

type tfaSecret struct {
AccessToken string `json:"access_token,omitempty"`
ProvisioningURI string `json:"provisioning_uri,omitempty"`
ProvisioningQR string `json:"provisioning_qr,omitempty"`
}

return func(w http.ResponseWriter, r *http.Request) {
d := requestBody{}
if ar.MustParseJSON(w, r, &d) != nil {
return
}

app := middleware.AppFromContext(r.Context())
if len(app.ID) == 0 {
ar.Error(w, ErrorAPIRequestAppIDInvalid, http.StatusBadRequest, "App is not in context.", "EnableTFA.AppFromContext")
Expand Down Expand Up @@ -69,25 +79,6 @@ func (ar *Router) EnableTFA() http.HandlerFunc {
return
}

if ar.tfaType == model.TFATypeSMS && user.Phone == "" {
ar.Error(w, ErrorAPIRequestPleaseSetPhoneForTFA, http.StatusBadRequest, "Please specify your phone number to be able to receive one-time passwords", "EnableTFA.setPhone")
return
}
if ar.tfaType == model.TFATypeEmail && user.Email == "" {
ar.Error(w, ErrorAPIRequestPleaseSetEmailForTFA, http.StatusBadRequest, "Please specify your email address to be able to receive one-time passwords", "EnableTFA.setEmail")
return
}

user.TFAInfo = model.TFAInfo{
IsEnabled: true,
Secret: gotp.RandomSecret(16),
}

if _, err := ar.server.Storages().User.UpdateUser(userID, user); err != nil {
ar.Error(w, ErrorAPIInternalServerError, http.StatusInternalServerError, err.Error(), "EnableTFA.UpdateUser")
return
}

tokenPayload, err := ar.getTokenPayloadForApp(app, user)
if err != nil {
ar.Error(w, ErrorAPIAppAccessTokenNotCreated, http.StatusInternalServerError, err.Error(), "EnableTFA.accessToken")
Expand All @@ -102,6 +93,18 @@ func (ar *Router) EnableTFA() http.HandlerFunc {

switch ar.tfaType {
case model.TFATypeApp:
// For app we just enable tfa and generate secret
user.TFAInfo = model.TFAInfo{
IsEnabled: true,
Secret: gotp.RandomSecret(16),
}

if _, err := ar.server.Storages().User.UpdateUser(userID, user); err != nil {
ar.Error(w, ErrorAPIInternalServerError, http.StatusInternalServerError, err.Error(), "EnableTFA.UpdateUser")
return
}

// Send new provising uri for authenticator
uri := gotp.NewDefaultTOTP(user.TFAInfo.Secret).ProvisioningUri(user.Username, app.Name)

var png []byte
Expand All @@ -115,6 +118,28 @@ func (ar *Router) EnableTFA() http.HandlerFunc {
ar.ServeJSON(w, http.StatusOK, &tfaSecret{ProvisioningURI: uri, ProvisioningQR: encoded, AccessToken: accessToken})
return
case model.TFATypeSMS, model.TFATypeEmail:
// If 2fa is SMS or Email we set it in TFAInfo and enable it only when it will be verified
if ar.tfaType == model.TFATypeSMS {
if d.Phone == "" {
ar.Error(w, ErrorAPIRequestPleaseSetPhoneForTFA, http.StatusBadRequest, "Please specify your phone number to be able to receive one-time passwords", "EnableTFA.setPhone")
return
}
user.TFAInfo = model.TFAInfo{Phone: d.Phone}
}
if ar.tfaType == model.TFATypeEmail {
if d.Email == "" {
ar.Error(w, ErrorAPIRequestPleaseSetEmailForTFA, http.StatusBadRequest, "Please specify your email address to be able to receive one-time passwords", "EnableTFA.setEmail")
return
}
user.TFAInfo = model.TFAInfo{Email: d.Email}
}

if _, err := ar.server.Storages().User.UpdateUser(userID, user); err != nil {
ar.Error(w, ErrorAPIInternalServerError, http.StatusInternalServerError, err.Error(), "EnableTFA.UpdateUser")
return
}

// And send OTP code for 2fa
if err := ar.sendOTPCode(user); err != nil {
ar.Error(w, ErrorAPIRequestUnableToSendOTP, http.StatusInternalServerError, err.Error(), "EnableTFA.sendOTP")
return
Expand Down Expand Up @@ -269,6 +294,19 @@ func (ar *Router) FinalizeTFA() http.HandlerFunc {
User: user,
}

// Enable TFA after verify if it not enabled
if !user.TFAInfo.IsEnabled {
user.TFAInfo = model.TFAInfo{
IsEnabled: true,
Secret: gotp.RandomSecret(16),
}

if _, err := ar.server.Storages().User.UpdateUser(userID, user); err != nil {
ar.Error(w, ErrorAPIInternalServerError, http.StatusInternalServerError, err.Error(), "EnableTFA.UpdateUser")
return
}
}

ar.server.Storages().User.UpdateLoginMetadata(user.ID)
ar.ServeJSON(w, http.StatusOK, result)
}
Expand Down Expand Up @@ -479,3 +517,70 @@ func (ar *Router) RequestTFAReset() http.HandlerFunc {
ar.ServeJSON(w, http.StatusOK, result)
}
}

// check2FA checks correspondence between app's TFAstatus and user's TFAInfo,
// and decides if we require two-factor authentication after all checks are successfully passed.
func (ar *Router) check2FA(appTFAStatus model.TFAStatus, serverTFAType model.TFAType, user model.User) (bool, bool, error) {
if appTFAStatus == model.TFAStatusMandatory && !user.TFAInfo.IsEnabled {
return true, false, errPleaseEnableTFA
}

if appTFAStatus == model.TFAStatusDisabled && user.TFAInfo.IsEnabled {
return false, true, errPleaseDisableTFA
}

// Request two-factor auth if user enabled it and app supports it.
if user.TFAInfo.IsEnabled && appTFAStatus != model.TFAStatusDisabled {
if user.TFAInfo.Phone == "" && serverTFAType == model.TFATypeSMS {
// Server required sms tfa but user phone is empty
return true, false, errPleaseSetPhoneTFA
}
if user.TFAInfo.Email == "" && serverTFAType == model.TFATypeEmail {
// Server required email tfa but user email is empty
return true, false, errPleaseSetEmailTFA
}
if user.TFAInfo.Secret == "" {
// Then admin must have enabled TFA for this user manually.
// User must obtain TFA secret, i.e send EnableTFA request.
return true, false, errPleaseEnableTFA
}
return true, true, nil
}
return false, false, nil
}

func (ar *Router) sendTFACodeInSMS(phone, otp string) error {
if phone == "" {
return errors.New("unable to send SMS OTP, user has no phone number")
}

if err := ar.server.Services().SMS.SendSMS(phone, fmt.Sprintf(smsTFACode, otp)); err != nil {
return fmt.Errorf("unable to send sms. %s", err)
}
return nil
}

func (ar *Router) sendTFACodeOnEmail(user model.User, otp string) error {
if user.TFAInfo.Email == "" {
return errors.New("unable to send email OTP, user has no email")
}

emailData := SendTFAEmailData{
User: user,
OTP: otp,
}

if err := ar.server.Services().Email.SendTemplateEmail(
model.EmailTemplateTypeTFAWithCode,
"One-time password",
user.TFAInfo.Email,
model.EmailData{
User: user,
Data: emailData,
},
); err != nil {
return fmt.Errorf("unable to send email with OTP with error: %s", err)
}

return nil
}
69 changes: 1 addition & 68 deletions web/api/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ func (ar *Router) sendOTPCode(user model.User) error {
}
switch ar.tfaType {
case model.TFATypeSMS:
return ar.sendTFACodeInSMS(user.Phone, otp)
return ar.sendTFACodeInSMS(user.TFAInfo.Phone, otp)
case model.TFATypeEmail:
return ar.sendTFACodeOnEmail(user, otp)
}
Expand Down Expand Up @@ -276,73 +276,6 @@ func (ar *Router) loginUser(user model.User, scopes []string, app model.AppData,
return accessTokenString, refreshTokenString, nil
}

// check2FA checks correspondence between app's TFAstatus and user's TFAInfo,
// and decides if we require two-factor authentication after all checks are successfully passed.
func (ar *Router) check2FA(appTFAStatus model.TFAStatus, serverTFAType model.TFAType, user model.User) (bool, bool, error) {
if appTFAStatus == model.TFAStatusMandatory && !user.TFAInfo.IsEnabled {
return true, false, errPleaseEnableTFA
}

if appTFAStatus == model.TFAStatusDisabled && user.TFAInfo.IsEnabled {
return false, true, errPleaseDisableTFA
}

// Request two-factor auth if user enabled it and app supports it.
if user.TFAInfo.IsEnabled && appTFAStatus != model.TFAStatusDisabled {
if user.Phone == "" && serverTFAType == model.TFATypeSMS {
// Server required sms tfa but user phone is empty
return true, false, errPleaseSetPhoneTFA
}
if user.Email == "" && serverTFAType == model.TFATypeEmail {
// Server required email tfa but user email is empty
return true, false, errPleaseSetEmailTFA
}
if user.TFAInfo.Secret == "" {
// Then admin must have enabled TFA for this user manually.
// User must obtain TFA secret, i.e send EnableTFA request.
return true, false, errPleaseEnableTFA
}
return true, true, nil
}
return false, false, nil
}

func (ar *Router) sendTFACodeInSMS(phone, otp string) error {
if phone == "" {
return errors.New("unable to send SMS OTP, user has no phone number")
}

if err := ar.server.Services().SMS.SendSMS(phone, fmt.Sprintf(smsTFACode, otp)); err != nil {
return fmt.Errorf("unable to send sms. %s", err)
}
return nil
}

func (ar *Router) sendTFACodeOnEmail(user model.User, otp string) error {
if user.Email == "" {
return errors.New("unable to send email OTP, user has no email")
}

emailData := SendTFAEmailData{
User: user,
OTP: otp,
}

if err := ar.server.Services().Email.SendTemplateEmail(
model.EmailTemplateTypeTFAWithCode,
"One-time password",
user.Email,
model.EmailData{
User: user,
Data: emailData,
},
); err != nil {
return fmt.Errorf("unable to send email with OTP with error: %s", err)
}

return 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
Expand Down
2 changes: 2 additions & 0 deletions web/api/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ const (
ErrorAPIRequestPleaseSetPhoneForTFA = "error.api.request.2fa.set_phone"
// ErrorAPIRequestPleaseSetEmailForTFA means that user must set up their email address to be able to receive OTPs on the email.
ErrorAPIRequestPleaseSetEmailForTFA = "error.api.request.2fa.set_email"
// ErrorAPIRequestEnableTFAEmptyPhoneAndEmail means that the email and phone is empty
ErrorAPIRequestEnableTFAEmptyPhoneAndEmail = "error.api.request.enable_2fa.empty_phone_and_email"
// ErrorAPIRequestUnableToSendOTP means that there is error sending the otp code while login to user
ErrorAPIRequestUnableToSendOTP = "error.api.request.2fa.unable to send OTP code to email or sms"

Expand Down
5 changes: 4 additions & 1 deletion web_apps_src/identifo.js/dist/identifo.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,10 @@ declare class API {
requestResetPassword(email: string, tfaCode?: string): Promise<SuccessResponse | TFARequiredRespopnse>;
resetPassword(password: string): Promise<SuccessResponse>;
getAppSettings(callbackUrl: string): Promise<AppSettingsResponse>;
enableTFA(): Promise<EnableTFAResponse>;
enableTFA(data: {
phone?: string;
email?: string;
}): Promise<EnableTFAResponse>;
verifyTFA(code: string, scopes: string[]): Promise<LoginResponse>;
resendTFA(): Promise<LoginResponse>;
logout(): Promise<SuccessResponse>;
Expand Down
12 changes: 5 additions & 7 deletions web_apps_src/identifo.js/dist/identifo.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web_apps_src/identifo.js/dist/identifo.js.map

Large diffs are not rendered by default.

12 changes: 5 additions & 7 deletions web_apps_src/identifo.js/dist/identifo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,13 @@ class API {
return this.get(`/auth/app_settings?${new URLSearchParams({ callbackUrl }).toString()}`);
});
}
enableTFA() {
enableTFA(data) {
return __async$3(this, null, function* () {
var _a, _b;
if (!((_a = this.tokenService.getToken()) == null ? void 0 : _a.token)) {
throw new Error("No token in token service.");
}
return this.put("/auth/tfa/enable", {}, {
return this.put("/auth/tfa/enable", data, {
headers: { [AUTHORIZATION_HEADER_KEY]: `BEARER ${(_b = this.tokenService.getToken()) == null ? void 0 : _b.token}` }
}).then((r) => this.storeToken(r));
});
Expand Down Expand Up @@ -929,7 +929,7 @@ class CDK {
setupTFA: () => __async(this, null, function* () {
})
});
const tfa = yield this.auth.api.enableTFA();
const tfa = yield this.auth.api.enableTFA({});
if (tfa.provisioning_uri) {
this.state.next({
route: Routes.TFA_SETUP_APP,
Expand All @@ -947,8 +947,7 @@ class CDK {
route: Routes.TFA_SETUP_EMAIL,
email: loginResponse.user.email || "",
setupTFA: (email) => __async(this, null, function* () {
yield this.auth.api.updateUser({ new_email: email });
yield this.auth.api.enableTFA();
yield this.auth.api.enableTFA({ email });
return this.tfaVerify(__spreadProps(__spreadValues({}, loginResponse), { user: __spreadProps(__spreadValues({}, loginResponse.user), { email }) }), type);
})
});
Expand All @@ -959,8 +958,7 @@ class CDK {
route: Routes.TFA_SETUP_SMS,
phone: loginResponse.user.phone || "",
setupTFA: (phone) => __async(this, null, function* () {
yield this.auth.api.updateUser({ new_phone: phone });
yield this.auth.api.enableTFA();
yield this.auth.api.enableTFA({ phone });
return this.tfaVerify(__spreadProps(__spreadValues({}, loginResponse), { user: __spreadProps(__spreadValues({}, loginResponse.user), { phone }) }), type);
})
});
Expand Down
2 changes: 1 addition & 1 deletion web_apps_src/identifo.js/dist/identifo.mjs.map

Large diffs are not rendered by default.

Loading

0 comments on commit 99db444

Please sign in to comment.