diff --git a/lib/NGCP/Panel/Block/Login/OtpRegistrationInfo.pm b/lib/NGCP/Panel/Block/Login/OtpRegistrationInfo.pm new file mode 100644 index 0000000000..f68d7e5cfb --- /dev/null +++ b/lib/NGCP/Panel/Block/Login/OtpRegistrationInfo.pm @@ -0,0 +1,13 @@ +package NGCP::Panel::Block::Login::OtpRegistrationInfo; + +use warnings; +use strict; + +use parent ("NGCP::Panel::Block::Block"); + +sub template { + my $self = shift; + return 'login/otp_registration_info.tt'; +} + +1; diff --git a/lib/NGCP/Panel/Controller/API/OTPSecret.pm b/lib/NGCP/Panel/Controller/API/OTPSecret.pm index 7feb3388c1..87eb144e43 100644 --- a/lib/NGCP/Panel/Controller/API/OTPSecret.pm +++ b/lib/NGCP/Panel/Controller/API/OTPSecret.pm @@ -3,8 +3,7 @@ package NGCP::Panel::Controller::API::OTPSecret; use parent qw/NGCP::Panel::Role::EntitiesItem NGCP::Panel::Role::API/; use Sipwise::Base; -use Imager::QRCode qw(); -use URI::Encode qw(uri_encode); +use NGCP::Panel::Utils::Auth qw(); use HTTP::Status qw(:constants); @@ -59,31 +58,11 @@ sub get_item_binary_data{ my($self, $c, $id, $item, $return_type) = @_; - # - my $qrcode = Imager::QRCode->new( - size => 4, - margin => 3, - version => 1, - level => 'M', - casesensitive => 1, - lightcolor => Imager::Color->new(255, 255, 255), - darkcolor => Imager::Color->new(0, 0, 0), - ); - - my $image = $qrcode->plot(sprintf("otpauth://totp/%s@%s?secret=%s&issuer=%s", - uri_encode($item->login), - uri_encode($c->req->uri->host), - $item->otp_secret, - 'NGCP', # . $c->config->{ngcp_version} - )); - - my $data; - $image->write(data => \$data, type => 'png') - or die $image->errstr; + my $data = NGCP::Panel::Utils::Auth::generate_otp_qr($c,$item); my $t = time(); - return \$data, 'image/png', "qrcode_$t.png"; + return $data, 'image/png', "qrcode_$t.png"; } diff --git a/lib/NGCP/Panel/Controller/Administrator.pm b/lib/NGCP/Panel/Controller/Administrator.pm index 37295c2638..17adf77c05 100644 --- a/lib/NGCP/Panel/Controller/Administrator.pm +++ b/lib/NGCP/Panel/Controller/Administrator.pm @@ -37,13 +37,15 @@ sub list_admin :PathPart('administrator') :Chained('/') :CaptureArgs(0) { { name => "email", search => 1, title => $c->loc("Email") }, $c->user->roles eq 'admin' || $c->user->roles eq 'reseller' ? ( + { name => "auth_mode", title => $c->loc("Authentication Mode")}, { name => "acl_role.role", title => $c->loc("Role")}, { name => "is_master", title => $c->loc("Master") }, { name => "is_active", title => $c->loc("Active") }, { name => "read_only", title => $c->loc("Read Only") }, - { name => "show_passwords", title => $c->loc("Show Passwords") }, - { name => "call_data", title => $c->loc("Show CDRs") }, - { name => "billing_data", title => $c->loc("Show Billing Info") }, + #{ name => "show_passwords", title => $c->loc("Show Passwords") }, + #{ name => "call_data", title => $c->loc("Show CDRs") }, + #{ name => "billing_data", title => $c->loc("Show Billing Info") }, + { name => "enable_2fa", title => $c->loc("2FA") }, { name => "can_reset_password", title => $c->loc("Can Reset Password") }, ) : () ); @@ -152,6 +154,15 @@ sub create :Chained('list_admin') :PathPart('create') :Args(0) :AllowedRole(admi $form->values->{md5pass} = undef; $form->values->{auth_mode} ||= 'local'; $form->values->{saltedpass} = NGCP::Panel::Utils::Auth::generate_salted_hash($password); + if ($form->values->{enable_2fa}) { + $form->values->{enable_2fa} = 1; + $form->values->{otp_secret} = NGCP::Panel::Utils::Auth::create_otp_secret(); + $form->values->{show_otp_registration_info} = 1; + } else { + $form->values->{enable_2fa} = 0; + $form->values->{otp_secret} = undef; + $form->values->{show_otp_registration_info} = 0; + } if ($form->values->{role_id}) { $form->values->%* = ( $form->values->%*, NGCP::Panel::Utils::UserRole::resolve_flags($c, $form->values->{role_id}) ); @@ -267,6 +278,17 @@ sub edit :Chained('base') :PathPart('edit') :Args(0) { $form->values->{md5pass} = undef; $form->values->{saltedpass} = NGCP::Panel::Utils::Auth::generate_salted_hash($password); } + + if ($form->values->{enable_2fa}) { + $form->values->{enable_2fa} = 1; + unless ($c->stash->{administrator}->otp_secret) { + $form->values->{otp_secret} = NGCP::Panel::Utils::Auth::create_otp_secret(); + $form->values->{show_otp_registration_info} = 1; + } + } else { + $form->values->{enable_2fa} = 0; + } + #should be after other fields, to remove all added values, e.g. reseller_id if($c->stash->{administrator}->login eq NGCP::Panel::Utils::Auth::get_special_admin_login()) { foreach my $field ($form->fields){ diff --git a/lib/NGCP/Panel/Controller/Login.pm b/lib/NGCP/Panel/Controller/Login.pm index 594af9cd83..1b0b00f1b6 100644 --- a/lib/NGCP/Panel/Controller/Login.pm +++ b/lib/NGCP/Panel/Controller/Login.pm @@ -6,6 +6,7 @@ use strict; use parent 'Catalyst::Controller'; use TryCatch; use UUID; +use MIME::Base64; use NGCP::Panel::Form; @@ -16,11 +17,20 @@ use NGCP::Panel::Utils::Subscriber; sub login_index :Path Form { my ( $self, $c, $realm ) = @_; - $realm = 'subscriber' - unless($realm && $realm eq 'admin'); - my $posted = ($c->req->method eq 'POST'); - my $form = NGCP::Panel::Form::get("NGCP::Panel::Form::Login", $c); + my $form; + + if ($c->request->params->{otp}) { + $form = NGCP::Panel::Form::get("NGCP::Panel::Form::LoginOtp", $c); + } else { + $form = NGCP::Panel::Form::get("NGCP::Panel::Form::Login", $c); + } + + if (not $realm + or $realm ne 'admin') { + $realm = 'subscriber'; + } + $form->process( posted => $posted, params => $c->request->params, @@ -32,7 +42,12 @@ sub login_index :Path Form { my $user = $form->field('username')->value; my $pass = $form->field('password')->value; my $otp; - $c->log->debug("Login::index user=$user, pass=****, realm=$realm"); + if ($form->field('otp')) { + $otp = $form->field('otp')->value; + $c->log->debug("Login::index user=$user, pass=****, otp=$otp, realm=$realm"); + } else { + $c->log->debug("Login::index user=$user, pass=****, realm=$realm"); + } my $res; if($realm eq 'admin') { $res = NGCP::Panel::Utils::Auth::perform_auth( @@ -56,9 +71,27 @@ sub login_index :Path Form { $res = NGCP::Panel::Utils::Auth::perform_subscriber_auth($c, $u, $d, $pass); } - if($res) { + if(defined $res && $res == -3) { + $form = NGCP::Panel::Form::get("NGCP::Panel::Form::LoginOtp", $c); + $form->field('username')->value($user); + $form->field('password')->value($pass); + $form->field('otp')->value(undef); + $form->add_form_error($c->loc('Invalid one-time code')) if $otp; + my $dbadmin = $c->model('DB')->resultset('admins')->search({ + login => $user, + })->first; + $c->stash(show_otp_registration_info => $dbadmin->show_otp_registration_info); + if ($dbadmin && $dbadmin->show_otp_registration_info) { + $c->stash( + otp_secret_qr_base64 => encode_base64(${NGCP::Panel::Utils::Auth::generate_otp_qr($c,$dbadmin)}), + ); + } + } elsif($res && $res == -2) { + $c->log->warn("invalid http login from '".$c->qs($c->req->address)."'"); + $c->log->debug("Login::index auth failed"); + $form->add_form_error($c->loc('User banned')) + } elsif($res) { # auth ok - if ($realm eq 'admin') { use Crypt::JWT qw/encode_jwt/; @@ -99,7 +132,8 @@ sub login_index :Path Form { } else { $c->log->warn("invalid http login from '".$c->qs($c->req->address)."'"); $c->log->debug("Login::index auth failed"); - $form->add_form_error($c->loc('Invalid username/password')); + $form->add_form_error($c->loc('Invalid username/password')) + } } else { # initial get @@ -291,7 +325,14 @@ sub change_password :Chained('/') :PathPart('changepassword') Args(0) { $c->user->logout if $c->user; my $posted = ($c->req->method eq 'POST'); - my $form = NGCP::Panel::Form::get("NGCP::Panel::Form::PasswordChange", $c); + my $form; + + if ($c->request->params->{otp}) { + $form = NGCP::Panel::Form::get("NGCP::Panel::Form::PasswordChangeOtp", $c); + } else { + $form = NGCP::Panel::Form::get("NGCP::Panel::Form::PasswordChange", $c); + } + $form->process( posted => $posted, params => $c->request->params, @@ -305,7 +346,12 @@ sub change_password :Chained('/') :PathPart('changepassword') Args(0) { my $otp; my $new_pass = $form->field('new_password')->value; my $new_pass2 = $form->field('new_password2')->value; - $c->log->debug("Password change user=$user, realm=$realm"); + if ($form->field('otp')) { + $otp = $form->field('otp')->value; + $c->log->debug("Password change user=$user, pass=****, otp=$otp, realm=$realm"); + } else { + $c->log->debug("Password change user=$user, pass=****, realm=$realm"); + } my $res; if($realm eq 'admin') { $res = NGCP::Panel::Utils::Auth::perform_auth( @@ -329,7 +375,28 @@ sub change_password :Chained('/') :PathPart('changepassword') Args(0) { $res = NGCP::Panel::Utils::Auth::perform_subscriber_auth($c, $u, $d, $pass); } - if($res) { + if(defined $res && $res == -3) { + $form = NGCP::Panel::Form::get("NGCP::Panel::Form::PasswordChangeOtp", $c); + $form->field('username')->value($user); + $form->field('password')->value($pass); + $form->field('otp')->value(undef); + $form->field('new_password')->value($new_pass); + $form->field('new_password')->value($new_pass2); + $form->add_form_error($c->loc('Invalid one-time code')) if $otp; + my $dbadmin = $c->model('DB')->resultset('admins')->search({ + login => $user, + })->first; + $c->stash(show_otp_registration_info => $dbadmin->show_otp_registration_info); + if ($dbadmin && $dbadmin->show_otp_registration_info) { + $c->stash( + otp_secret_qr_base64 => encode_base64(${NGCP::Panel::Utils::Auth::generate_otp_qr($c,$dbadmin)}), + ); + } + } elsif($res && $res == -2) { + $c->log->warn("invalid http login from '".$c->qs($c->req->address)."'"); + $c->log->debug("Login::index auth failed"); + $form->add_form_error($c->loc('User banned')) + } elsif($res) { # auth ok if ($pass eq $new_pass) { diff --git a/lib/NGCP/Panel/Controller/Root.pm b/lib/NGCP/Panel/Controller/Root.pm index df6bd60154..758e6893f7 100644 --- a/lib/NGCP/Panel/Controller/Root.pm +++ b/lib/NGCP/Panel/Controller/Root.pm @@ -52,6 +52,9 @@ sub auto :Private { $c->log->debug("Root::auto enable cache"); NGCP::Panel::Form::dont_use_cache(0); $is_api_request = 1; + #} elsif ('NGCP::Panel::Controller::Login' eq $c->controller->catalyst_component_name) { + # $c->log->debug("Root::auto disable cache"); + # NGCP::Panel::Form::dont_use_cache(0); } else { $c->log->debug("Root::auto disable cache"); NGCP::Panel::Form::dont_use_cache(1); diff --git a/lib/NGCP/Panel/Field/Password.pm b/lib/NGCP/Panel/Field/Password.pm new file mode 100644 index 0000000000..d8b24fa3fe --- /dev/null +++ b/lib/NGCP/Panel/Field/Password.pm @@ -0,0 +1,35 @@ +package NGCP::Panel::Field::Password; +use HTML::FormHandler::Moose; +extends 'HTML::FormHandler::Field::Password'; + +sub fif { + my ( $self, $result ) = @_; + return if ( $self->inactive && !$self->_active ); + #return '' if $self->password; + return unless $result || $self->has_result; + my $lresult = $result || $self->result; + if ( ( $self->has_result && $self->has_input && !$self->fif_from_value ) || + ( $self->fif_from_value && !defined $lresult->value ) ) + { + return defined $lresult->input ? $lresult->input : ''; + } + if ( $lresult->has_value ) { + my $value; + if( $self->_can_deflate ) { + $value = $self->_apply_deflation($lresult->value); + } + else { + $value = $lresult->value; + } + return ( defined $value ? $value : '' ); + } + elsif ( defined $self->value ) { + # this is because checkboxes and submit buttons have their own 'value' + # needs to be fixed in some better way + return $self->value; + } + return ''; +} + +no Moose; +1; diff --git a/lib/NGCP/Panel/Form/Administrator/Admin.pm b/lib/NGCP/Panel/Form/Administrator/Admin.pm index 15b4034cf9..368a355cb8 100644 --- a/lib/NGCP/Panel/Form/Administrator/Admin.pm +++ b/lib/NGCP/Panel/Form/Administrator/Admin.pm @@ -14,7 +14,7 @@ has_block 'fields' => ( tag => 'div', class => [qw(modal-body)], render_list => [qw( - reseller login auth_mode password email role_id is_master is_active read_only show_passwords call_data billing_data can_reset_password + reseller login auth_mode password email role_id is_master is_active read_only enable_2fa show_passwords call_data billing_data can_reset_password )], ); diff --git a/lib/NGCP/Panel/Form/Administrator/Reseller.pm b/lib/NGCP/Panel/Form/Administrator/Reseller.pm index 38381ae498..3780687886 100644 --- a/lib/NGCP/Panel/Form/Administrator/Reseller.pm +++ b/lib/NGCP/Panel/Form/Administrator/Reseller.pm @@ -23,7 +23,7 @@ has_field 'auth_mode' => ( has_field 'password' => (type => 'Password', required => 1, label => 'Password'); has_field 'email' => (type => 'Email', required => 0, label => 'Email', maxlength => 255); for (qw(is_active show_passwords call_data billing_data - is_master is_ccare read_only can_reset_password)) { + is_master is_ccare read_only enable_2fa can_reset_password)) { has_field $_ => (type => 'Boolean', default_method => \&_set_default); } has_field 'save' => (type => 'Submit', element_class => [qw(btn btn-primary)],); @@ -31,7 +31,7 @@ has_block 'fields' => ( tag => 'div', class => [qw(modal-body)], render_list => [qw( - login auth_mode password email is_master is_active read_only show_passwords call_data billing_data can_reset_password + login auth_mode password enable_2fa email is_master is_active enable_2fa read_only show_passwords call_data billing_data can_reset_password )], ); has_block 'actions' => (tag => 'div', class => [qw(modal-footer)], render_list => [qw(save)],); @@ -47,6 +47,12 @@ sub _set_default { $field->default(1); } + if (grep { $field->name eq $_ } + qw(is_active show_passwords call_data billing_data)) { + + $field->default(0); + } + if ($field->name eq 'auth_mode') { $field->default('local'); } diff --git a/lib/NGCP/Panel/Form/Administrator/System.pm b/lib/NGCP/Panel/Form/Administrator/System.pm index d7420dd0ec..7ac6267b19 100644 --- a/lib/NGCP/Panel/Form/Administrator/System.pm +++ b/lib/NGCP/Panel/Form/Administrator/System.pm @@ -12,7 +12,7 @@ has_block 'fields' => ( tag => 'div', class => [qw(modal-body)], render_list => [qw( - reseller login auth_mode password email role_id is_master is_active read_only show_passwords call_data billing_data can_reset_password + reseller login auth_mode password email role_id is_master is_active read_only enable_2fa show_passwords call_data billing_data can_reset_password )], ); diff --git a/lib/NGCP/Panel/Form/LoginOtp.pm b/lib/NGCP/Panel/Form/LoginOtp.pm new file mode 100644 index 0000000000..2486c34036 --- /dev/null +++ b/lib/NGCP/Panel/Form/LoginOtp.pm @@ -0,0 +1,56 @@ +package NGCP::Panel::Form::LoginOtp; + +use HTML::FormHandler::Moose; +extends 'HTML::FormHandler'; + +use HTML::FormHandler::Widget::Block::Bootstrap; +use NGCP::Panel::Field::Password qw(); + +has '+widget_wrapper' => ( default => 'Bootstrap' ); + +sub build_render_list { + my $self = shift; + my @list = qw(username password); + push(@list,'otp_registration_info') if $self->{ctx}->stash->{'show_otp_registration_info'}; + push(@list,"otp","submit"); + return \@list; +} +sub build_form_tags {{ error_class => 'label label-secondary'}} + +has_field 'username' => ( + type => 'Text', + required => 1, + element_attr => { readonly => 1, placeholder => 'Username' }, + element_class => [qw/login username-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_field 'password' => ( + type => '+NGCP::Panel::Field::Password', + required => 1, + element_attr => { readonly => 1, placeholder => 'Password' }, + element_class => [qw/login password-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_block 'otp_registration_info' => ( + type => '+NGCP::Panel::Block::Login::OtpRegistrationInfo', +); + +has_field 'otp' => ( + type => 'Text', + required => 1, + element_attr => { placeholder => 'One-Time Code' }, + element_class => [qw/login otp-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_field 'submit' => ( + type => 'Submit', + value => 'Sign In', + label => '', + element_class => [qw/button btn btn-primary btn-large/], +); + +1; + diff --git a/lib/NGCP/Panel/Form/PasswordChangeOtp.pm b/lib/NGCP/Panel/Form/PasswordChangeOtp.pm new file mode 100644 index 0000000000..2f59d4a3fa --- /dev/null +++ b/lib/NGCP/Panel/Form/PasswordChangeOtp.pm @@ -0,0 +1,71 @@ +package NGCP::Panel::Form::PasswordChangeOtp; + +use HTML::FormHandler::Moose; +extends 'HTML::FormHandler'; + +use HTML::FormHandler::Widget::Block::Bootstrap; + +has '+widget_wrapper' => ( default => 'Bootstrap' ); + +sub build_render_list { + my $self = shift; + my @list = qw(username password); + push(@list,'otp_registration_info') if $self->{ctx}->stash->{'show_otp_registration_info'}; + push(@list,"otp","new_password","new_password2", "submit"); + return \@list; +} +sub build_form_tags {{ error_class => 'label label-secondary'}} + +has_field 'username' => ( + type => 'Text', + required => 1, + element_attr => { readonly => 1, placeholder => 'Username' }, + element_class => [qw/login username-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_field 'password' => ( + type => '+NGCP::Panel::Field::Password', + required => 1, + element_attr => { readonly => 1, placeholder => 'Password' }, + element_class => [qw/login password-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_block 'otp_registration_info' => ( + type => '+NGCP::Panel::Block::Login::OtpRegistrationInfo', +); + +has_field 'otp' => ( + type => 'Text', + required => 1, + element_attr => { placeholder => 'One-Time Code' }, + element_class => [qw/login otp-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_field 'new_password' => ( + type => 'Password', + required => 1, + element_attr => { placeholder => 'New Password' }, + element_class => [qw/login password-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_field 'new_password2' => ( + type => 'Password', + required => 1, + element_attr => { placeholder => 'New Password Again' }, + element_class => [qw/login password-field/], + wrapper_class => [qw/login-fields field control-group/], +); + +has_field 'submit' => ( + type => 'Submit', + value => 'Submit', + label => '', + element_class => [qw/button btn btn-primary btn-large/], +); + +1; + diff --git a/lib/NGCP/Panel/Utils/Auth.pm b/lib/NGCP/Panel/Utils/Auth.pm index b212baddb0..947a98f9fa 100644 --- a/lib/NGCP/Panel/Utils/Auth.pm +++ b/lib/NGCP/Panel/Utils/Auth.pm @@ -9,6 +9,8 @@ use UUID; use Bytes::Random::Secure qw(); use MIME::Base32 qw(); use Digest::HMAC_SHA1 qw(); +use Imager::QRCode qw(); +use URI::Encode qw(uri_encode); use NGCP::Panel::Utils::Ldap qw( auth_ldap_simple @@ -175,12 +177,18 @@ sub perform_auth { if ($res and not $skip_otp - and $dbadmin->enable_2fa - and not verify_otp($c,$dbadmin->otp_secret,$otp,time())) { - $res = -3; + and $dbadmin->enable_2fa) { + if (verify_otp($c,$dbadmin->otp_secret,$otp,time())) { + $dbadmin->update({ + show_otp_registration_info => 0, + }) if ($dbadmin->show_otp_registration_info); + } else { + $res = -3; + } + } - $res > 0 ? do { + (defined $res and $res > 0) ? do { clear_failed_login_attempts($c, $user, 'admin'); reset_ban_increment_stage($c, $user, 'admin'); } @@ -826,14 +834,49 @@ sub create_otp_secret { } +sub generate_otp_qr { + + my ($c,$admin) = @_; + + # + my $qrcode = Imager::QRCode->new( + size => 4, + margin => 3, + version => 1, + level => 'M', + casesensitive => 1, + #i_background => Imager::Color->new("#FFF"), + #background => Imager::Color->new("#FFF"), + lightcolor => Imager::Color->new(255, 255, 255, 0.0), + darkcolor => Imager::Color->new(0, 0, 0), + ); + + my $image = $qrcode->plot(sprintf("otpauth://totp/%s@%s?secret=%s&issuer=%s", + uri_encode($admin->login), + uri_encode($c->req->uri->host), + $admin->otp_secret, + 'NGCP', # . $c->config->{ngcp_version} + )); + + my $data; + $image->write(data => \$data, type => 'png') + or die $image->errstr; + + return \$data; + +} + sub verify_otp { my ($c, $otp_secret, $otp, $time) = @_; - #$c->log->debug("verify otp: $otp, secret $otp_secret"); - #return 1; - - $c->log->debug("verify otp: " . ($otp // "") . ", secret " . ($otp_secret // "")); + my @dbg_msg = (); + push(@dbg_msg,$otp) if $otp; + push(@dbg_msg,"no otp provided") unless $otp; + push(@dbg_msg,'secret ' . $otp_secret) if $otp_secret; + push(@dbg_msg,'no secret') unless $otp_secret; + push(@dbg_msg,'time ' . $time) if $time; + $c->log->debug("verify otp: " . join(', ',@dbg_msg)); return 0 unless $otp; return 0 unless $otp_secret; @@ -843,7 +886,7 @@ sub verify_otp { for my $i (-int(($OTP_WINDOW - 1) / 2) .. int($OTP_WINDOW / 2)) { my $hash = _verify_otp_bytes($secret, int($time / $OTP_STEP_SIZE) + $i); - return 1 if $hash == $otp; + return 1 if $hash eq $otp; } return 0; diff --git a/share/static/css/application.css b/share/static/css/application.css index fae0aba2a8..3121d52edd 100644 --- a/share/static/css/application.css +++ b/share/static/css/application.css @@ -1090,6 +1090,9 @@ body.login { .login-fields .password-field { background: url(../img/signin/password.png) no-repeat; } +.login-fields .otp-field { + background: url(../img/signin/otp.png) no-repeat; +} .login-actions { float: left; width: 100%; diff --git a/share/static/img/signin/otp.png b/share/static/img/signin/otp.png new file mode 100644 index 0000000000..68bbfabf5b Binary files /dev/null and b/share/static/img/signin/otp.png differ diff --git a/share/templates/login/otp_registration_info.tt b/share/templates/login/otp_registration_info.tt new file mode 100644 index 0000000000..8317d9c1cc --- /dev/null +++ b/share/templates/login/otp_registration_info.tt @@ -0,0 +1,17 @@ + + + + + +
+1. Install the Google Authenticator app on you mobile device. +
+Get it on Google Play +Download on the App Store +
+2. Open the app to register your NGCP account (scan QR code below). +
+ +
+3. Use the app to generate the requested verification code. +
\ No newline at end of file