TOTP.pm 10.4 KB
Newer Older
Christophe Maudoux's avatar
Christophe Maudoux committed
1
# Self TOTP registration
2 3 4 5
package Lemonldap::NG::Portal::2F::Register::TOTP;

use strict;
use Mouse;
6
use JSON qw(from_json to_json);
7 8 9

our $VERSION = '2.0.0';

10
extends 'Lemonldap::NG::Portal::Main::Plugin', 'Lemonldap::NG::Common::TOTP';
11 12 13

# INITIALIZATION

14 15
has prefix => ( is => 'rw', default => 'totp' );

Christophe Maudoux's avatar
Christophe Maudoux committed
16
has template => ( is => 'ro', default => 'totp2fregister' );
17

18 19
has logo => ( is => 'rw', default => 'totp.png' );

20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
has ott => (
    is      => 'rw',
    lazy    => 1,
    default => sub {
        my $ott =
          $_[0]->{p}->loadModule('Lemonldap::NG::Portal::Lib::OneTimeToken');
        $ott->timeout( $_[0]->conf->{formTimeout} );
        return $ott;
    }
);

sub init {
    return 1;
}

35 36
sub run {
    my ( $self, $req, $action ) = @_;
37
    my $user = $req->userData->{ $self->conf->{whatToTrace} };
38 39
    unless ($user) {
        return $self->p->sendError( $req,
40
            'No ' . $self->conf->{whatToTrace} . ' found in user data', 500 );
41 42 43
    }

    # Verification that user has a valid TOTP app
Christophe Maudoux's avatar
Christophe Maudoux committed
44
    if ( $action eq 'verify' ) {
45 46 47 48 49 50

        # Get form token
        my $token = $req->param('token');
        unless ($token) {
            $self->userLogger->warn(
                "TOTP registration: register try without token for $user");
51
            return $self->p->sendError( $req, 'noTOTPFound', 400 );
52 53 54 55 56 57 58 59 60 61 62 63 64 65
        }

        # Verify that token exists in DB (note that "keep" flag is set to
        # permit more than 1 try during token life
        unless ( $token = $self->ott->getToken( $token, 1 ) ) {
            $self->userLogger->notice(
                "TOTP registration: token expired for $user");
            return $self->p->sendError( $req, 'PE82', 400 );
        }

        # Token is valid, so we have the master key proposed
        # ($token->{_totp2fSecret})

        # Now check TOTP code to verify that user has a valid TOTP app
66 67
        my $code     = $req->param('code');
        my $TOTPName = $req->param('TOTPName');
68 69
        my $epoch    = time();

Christophe Maudoux's avatar
Christophe Maudoux committed
70
        # Set default name if empty, check characters and truncate name if too long
71
        $TOTPName ||= $epoch;
Christophe Maudoux's avatar
Christophe Maudoux committed
72 73 74 75
        unless ( $TOTPName =~ /^[\w]+$/ ) {
            $self->userLogger->error('TOTP name with bad character(s)');
            return $self->p->sendError( $req, 'badName', 200 );
        }
76 77 78
        $TOTPName =
          substr( $TOTPName, 0, $self->conf->{max2FDevicesNameLength} );
        $self->logger->debug("TOTP name : $TOTPName");
79 80

        unless ($code) {
Xavier Guimard's avatar
Xavier Guimard committed
81
            $self->userLogger->info('TOTP registration: empty validation form');
82
            return $self->p->sendError( $req, 'missingCode', 200 );
83
        }
84

85 86 87
        my $r = $self->verifyCode(
            $self->conf->{totp2fInterval},
            $self->conf->{totp2fRange},
Xavier Guimard's avatar
Xavier Guimard committed
88
            $self->conf->{totp2fDigits},
89 90 91
            $token->{_totp2fSecret}, $code
        );
        if ( $r == -1 ) {
Xavier Guimard's avatar
Xavier Guimard committed
92
            return $self->p->sendError( $req, 'serverError', 500 );
93 94 95 96 97 98 99 100 101 102 103
        }

        # Invalid try is returned with a 200 code. Javascript will read error
        # and propose to retry
        elsif ( $r == 0 ) {
            $self->userLogger->notice(
                "TOTP registration: invalid TOTP for $user");
            return $self->p->sendError( $req, 'badCode', 200 );
        }
        $self->logger->debug('TOTP code verified');

104
        # Now code is verified, let's store the master key in persistent data
Christophe Maudoux's avatar
Christophe Maudoux committed
105

Xavier Guimard's avatar
Xavier Guimard committed
106
        my $secret = '';
Christophe Maudoux's avatar
Christophe Maudoux committed
107

Xavier Guimard's avatar
Xavier Guimard committed
108 109 110 111 112 113 114 115 116 117 118 119 120 121
        # Reading existing 2FDevices
        $self->logger->debug("Looking for 2F Devices ...");
        my $_2fDevices;
        if ( $req->userData->{_2fDevices} ) {
            $_2fDevices = eval {
                from_json( $req->userData->{_2fDevices},
                    { allow_nonref => 1 } );
            };
            if ($@) {
                $self->logger->error("Corrupted session (_2fDevices): $@");
                return $self->p->sendError( $req, "Corrupted session", 500 );
            }
        }
        else {
122
            $self->logger->debug("No 2F Device found");
Christophe Maudoux's avatar
Christophe Maudoux committed
123
            $_2fDevices = [];
Christophe Maudoux's avatar
Christophe Maudoux committed
124 125 126
        }

        # Reading existing TOTP
127
        my @totp2f = grep { $_->{type} eq "TOTP" } @$_2fDevices;
Christophe Maudoux's avatar
Christophe Maudoux committed
128
        unless (@totp2f) {
129 130 131
            $self->logger->debug("No TOTP Device found");

            # Set default value
Christophe Maudoux's avatar
Christophe Maudoux committed
132
            push @totp2f, { _secret => '' };
133
        }
Christophe Maudoux's avatar
Christophe Maudoux committed
134

135
        # Loading TOTP secret
Christophe Maudoux's avatar
Christophe Maudoux committed
136 137 138
        foreach (@totp2f) {
            $self->logger->debug("Reading TOTP secret if exists ...");
            $secret = $_->{_secret};
139
        }
Christophe Maudoux's avatar
Christophe Maudoux committed
140

141
        if ( $token->{_totp2fSecret} eq $secret ) {
Christophe Maudoux's avatar
Christophe Maudoux committed
142 143
            return $self->p->sendError( $req, 'totpExistingKey', 200 );
        }
144

Christophe Maudoux's avatar
Christophe Maudoux committed
145
        ### USER CAN ONLY REGISTER ONE TOTP ###
146
        # Delete TOTP previously registered
Christophe Maudoux's avatar
Christophe Maudoux committed
147
        my @keep = ();
148 149 150 151
        while (@$_2fDevices) {
            my $element = shift @$_2fDevices;
            $self->logger->debug("Looking for TOTP to delete ...");
            push @keep, $element unless ( $element->{type} eq "TOTP" );
152
        }
153 154

        # Check if user can register one more device
155
        my $size    = @keep;
156 157 158 159 160 161 162
        my $maxSize = $self->conf->{max2FDevices};
        $self->logger->debug("Nbr 2FDevices = $size / $maxSize");
        if ( $size >= $maxSize ) {
            $self->userLogger->error("Max number of 2F devices is reached !!!");
            return $self->p->sendError( $req, 'maxNumberof2FDevicesReached',
                400 );
        }
163

Christophe Maudoux's avatar
Christophe Maudoux committed
164
        # Store TOTP secret
165
        push @keep,
166
          {
167
            type    => 'TOTP',
168 169
            name    => $TOTPName,
            _secret => $token->{_totp2fSecret},
170
            epoch   => $epoch
171
          };
172

173
        $self->logger->debug(
174
            "Append 2F Device : { type => 'TOTP', name => $TOTPName }");
175
        $self->p->updatePersistentSession( $req,
176
            { _2fDevices => to_json( \@keep ) } );
177
        $self->userLogger->notice('TOTP registration succeed');
178 179 180 181 182
        return [
            200,
            [ 'Content-Type' => 'application/json', 'Content-Length' => 12, ],
            ['{"result":1}']
        ];
183
    }
184

185
    # Get or generate master key
186
    elsif ( $action eq 'getkey' ) {
Christophe Maudoux's avatar
Christophe Maudoux committed
187
        my $nk     = 0;
188
        my $secret = '';
Christophe Maudoux's avatar
Christophe Maudoux committed
189

Xavier Guimard's avatar
Xavier Guimard committed
190
        # Read existing 2FDevices
191 192
        $self->logger->debug("Looking for 2F Devices ...");
        my $_2fDevices;
Xavier Guimard's avatar
Xavier Guimard committed
193 194 195 196 197 198 199 200 201 202
        if ( $req->userData->{_2fDevices} ) {
            $_2fDevices = eval {
                from_json( $req->userData->{_2fDevices},
                    { allow_nonref => 1 } );
            };
            if ($@) {
                $self->logger->error("Corrupted session (_2fDevices): $@");
                return $self->p->sendError( $req, "Corrupted session", 500 );
            }
        }
Christophe Maudoux's avatar
Christophe Maudoux committed
203

204 205 206 207 208
        else {
            $self->logger->debug("No 2F Device found");
            $_2fDevices = [];
        }

Christophe Maudoux's avatar
Christophe Maudoux committed
209
        # Loading TOTP secret
210
        my @totp2f = grep { $_->{type} eq "TOTP" } @$_2fDevices;
Christophe Maudoux's avatar
Christophe Maudoux committed
211
        unless (@totp2f) {
212
            $self->logger->debug("No TOTP found");
213 214

            # Set default value
Christophe Maudoux's avatar
Christophe Maudoux committed
215 216 217 218 219 220 221 222 223
            push @totp2f, { _secret => '' };
        }

        # Loading TOTP secret
        foreach (@totp2f) {
            $self->logger->debug("Reading TOTP secret if exists ...");
            $secret = $_->{_secret};
        }

224
        if ( ( $req->param('newkey') and $self->conf->{totp2fUserCanChangeKey} )
225
            or not $secret )
226
        {
227
            $secret = $self->newSecret;
228
            $self->logger->debug("Generating new secret = $secret");
Christophe Maudoux's avatar
Christophe Maudoux committed
229
            $nk = 1;
230
        }
Christophe Maudoux's avatar
Christophe Maudoux committed
231

232
        elsif ( $req->param('newkey') ) {
Christophe Maudoux's avatar
Typo  
Christophe Maudoux committed
233
            return $self->p->sendError( $req, 'notAuthorized', 200 );
234
        }
Christophe Maudoux's avatar
Christophe Maudoux committed
235

236
        elsif ( $self->conf->{totp2fDisplayExistingSecret} ) {
237
            $self->logger->debug("User secret = $secret");
238
        }
Christophe Maudoux's avatar
Christophe Maudoux committed
239

240 241
        else {
            return $self->p->sendError( $req, 'totpExistingKey', 200 );
Christophe Maudoux's avatar
Christophe Maudoux committed
242
        }
243 244 245 246 247 248 249 250 251

        # Secret is stored in a token: we choose to not accept secret returned
        # by Ajax request to avoid some attacks
        my $token = $self->ott->createToken(
            {
                _totp2fSecret => $secret,
            }
        );

Xavier Guimard's avatar
Xavier Guimard committed
252 253 254 255 256
        my $issuer;
        unless ( $issuer = $self->conf->{totp2fIssuer} ) {
            $issuer = $self->conf->{portal};
            $issuer =~ s#^https?://([^/:]+).*$#$1#;
        }
257 258 259 260 261

        # QR-code will be generated by a javascript, here we just send data
        return $self->p->sendJSONresponse(
            $req,
            {
Xavier Guimard's avatar
Xavier Guimard committed
262 263 264 265 266 267 268
                secret   => $secret,
                token    => $token,
                portal   => $issuer,
                user     => $user,
                newkey   => $nk,
                digits   => $self->conf->{totp2fDigits},
                interval => $self->conf->{totp2fInterval}
269 270
            }
        );
Christophe Maudoux's avatar
Christophe Maudoux committed
271
    }
272

273
    # Delete TOTP
274
    elsif ( $action eq 'delete' ) {
Xavier Guimard's avatar
Xavier Guimard committed
275

276 277 278 279 280
        # Check if unregistration is allowed
        unless ( $self->conf->{totp2fUserCanRemoveKey} ) {
            return $self->p->sendError( $req, 'notAuthorized', 400 );
        }

281 282 283 284
        my $epoch = $req->param('epoch')
          or return $self->p->sendError( $req, '"epoch" parameter is missing',
            400 );

Xavier Guimard's avatar
Xavier Guimard committed
285 286
        # Read existing 2FDevices
        $self->logger->debug("Loading 2F Devices ...");
287
        my $_2fDevices;
Xavier Guimard's avatar
Xavier Guimard committed
288 289 290 291 292 293 294 295 296 297
        if ( $req->userData->{_2fDevices} ) {
            $_2fDevices = eval {
                from_json( $req->userData->{_2fDevices},
                    { allow_nonref => 1 } );
            };
            if ($@) {
                $self->logger->error("Corrupted session (_2fDevices): $@");
                return $self->p->sendError( $req, "Corrupted session", 500 );
            }
        }
298

299 300 301 302 303
        else {
            $self->logger->debug("No 2F Device found");
            $_2fDevices = [];
        }

Christophe Maudoux's avatar
Christophe Maudoux committed
304
        # Delete TOTP 2F device
305
        my @keep = ();
Christophe Maudoux's avatar
Christophe Maudoux committed
306 307
        while (@$_2fDevices) {
            my $element = shift @$_2fDevices;
308
            $self->logger->debug("Looking for 2F device to delete ...");
309 310 311 312 313 314
            push @keep, $element unless ( $element->{epoch} eq $epoch );
        }

        $self->logger->debug(
            "Delete 2F Device : { type => 'TOTP', epoch => $epoch }");
        $self->p->updatePersistentSession( $req,
Christophe Maudoux's avatar
Christophe Maudoux committed
315
            { _2fDevices => to_json( \@keep ) } );
316
        $self->userLogger->notice('TOTP deletion succeed');
317 318 319 320 321 322 323 324 325
        return [
            200,
            [ 'Content-Type' => 'application/json', 'Content-Length' => 12, ],
            ['{"result":1}']
        ];
    }
    else {
        $self->logger->error("Unknown TOTP action -> $action");
        return $self->p->sendError( $req, 'unknownAction', 400 );
326
    }
327 328 329
}

1;