Google Authenticator iRule For Two-Factor Auth With LDAP
Problem this snippet solves:
This iRule requires LTM v10. or higher.
This iRule adds two-factor authentication to a virtual server by combining an LDAP account with a Google Authenticator token.
The implementation is described in George Watkins' article: Two Factor Authentication with Google Authenticator
The iRule should be added to an LDAP authentication profile on an LTM, then applied to a virtual server. The users' Google Authenticator secrets are mapped using a data group defined by the 'user_to_google_auth_class' variable in the RULE_INIT section of the iRule. Here are a list of all the configurable options:
- auth_cookie - name of cookie used to track user's authentication status
- auth_cookie_aes_key - key used to encrypt user's cookie to prevent tampering
- auth_timeout - defines how much time is allowed to elapse before the user's session become invalid
- auth_lifetime - defines a finite period of validity for user's session, set to 0 for indefinite
- user_to_google_auth_class - name of data group that contains user to Google Authenticator secret mappings
- lockout_attempts - number of attempts a user is allowed to make prior to being locked out temporarily
- lockout_period - duration of lockout period
- logging - log level - 0 - logging off, 1 - log only successes, failures, and lockouts, 2 - log every attempt to access virtual as well as authentication process details
- login_page - HTML for login page presented to user (could alternatively be housed on application server)
Code :
when RULE_INIT { # auth parameters set static::auth_cookie "bigip_virtual_auth" set static::auth_cookie_aes_key "AES 128 abcdef1234567890abcdef1234567890" set static::auth_timeout 86400 set static::auth_lifetime 86400 # name of datagroup that holds AD user to Google Authenticator mappings set static::user_to_google_auth_class "user_to_google_auth" # lock the user out after x attempts for a period of x seconds set static::lockout_attempts 3 set static::lockout_period 30 # 0 - logging off # 1 - log only successes, failures, and lockouts # 2 - log every attempt to access virtual as well as authentication process details set static::logging 1 # HTML for login page set static::login_page {} } when CLIENT_ACCEPTED { # per virtual status tables for lockouts and users' auth_status set lockout_state_table "[virtual name]_lockout_status" set auth_status_table "[virtual name]_auth_status" set authid_to_user_table "[virtual name]_authid_to_user" # record client IP, [IP::client_addr] not available in AUTH_RESULT set user_ip [IP::client_addr] # set initial values for auth_id and auth_status set auth_id [AUTH::start pam default_ldap] set auth_status 2 } when HTTP_REQUEST { # track original URI user requested prior to login redirect set orig_uri [b64encode [HTTP::uri]] if { [HTTP::cookie exists $static::auth_cookie] && !([HTTP::path] starts_with "/login")} { set auth_id_current [AES::decrypt $static::auth_cookie_aes_key [b64decode [HTTP::cookie value $static::auth_cookie]]] set auth_status [table lookup -notouch -subtable $auth_status_table $auth_id_current] set user [table lookup -notouch -subtable $authid_to_user_table $auth_id_current] if { $auth_status == 0 } { if { $static::logging >= 2 } { log local0. "$user ($user_ip): Found valid auth cookie (auth_id=$auth_id_current), passing request through" } } else { if { $static::logging >= 2 } { log local0. "Found invalid auth cookie (auth_id=$auth_id_current), redirecting to login"} HTTP::redirect "/login?orig_uri=$orig_uri" } } elseif { ([HTTP::path] starts_with "/login") && ([HTTP::method] eq "GET") } { HTTP::respond 200 content $static::login_page } elseif { ([HTTP::path] starts_with "/login") && ([HTTP::method] eq "POST") } { set orig_uri [b64decode [URI::query [HTTP::uri] "orig_uri"]] HTTP::collect [HTTP::header Content-Length] } else { if { $static::logging >= 2 } { log local0. "Request for [HTTP::uri] from unauthenticated client ($user_ip), redirecting to login" } HTTP::redirect "/login?orig_uri=$orig_uri" } } when HTTP_REQUEST_DATA { set user "" set pass "" set ga_code "" foreach param [split [HTTP::payload] &] { set [lindex [split $param =] 0] [lindex [split $param =] 1] } if { ($user ne "") && ($pass ne "") && ([string length $ga_code] == 6) } { set ga_code_b32 [class lookup $user $static::user_to_google_auth_class] set prev_attempts [table incr -notouch -subtable $lockout_state_table $user] table timeout -subtable $lockout_state_table $user $static::lockout_period if { $prev_attempts <= $static::lockout_attempts } { if { [string length $ga_code_b32] == 16 } { if { $static::logging >= 2 } { log local0. "$user ($user_ip): Starting authentication sequence, attempt #$prev_attempts" } # begin - Base32 decode to binary # Base32 alphabet (see RFC 4648) array set static::b32_alphabet { A 0 B 1 C 2 D 3 E 4 F 5 G 6 H 7 I 8 J 9 K 10 L 11 M 12 N 13 O 14 P 15 Q 16 R 17 S 18 T 19 U 20 V 21 W 22 X 23 Y 24 Z 25 2 26 3 27 4 28 5 29 6 30 7 31 } set l [string length $ga_code_b32] set n 0 set j 0 set ga_code_bin "" for { set i 0 } { $i < $l } { incr i } { set n [expr $n << 5] set n [expr $n + $static::b32_alphabet([string index $ga_code_b32 $i])] set j [incr j 5] if { $j >= 8 } { set j [incr j -8] append ga_code_bin [format %c [expr ($n & (0xFF << $j)) >> $j]] } } # end - Base32 decode to binary # begin - HMAC-SHA1 calculation of Google Auth token set time [binary format W* [expr [clock seconds] / 30]] set ipad "" set opad "" for { set j 0 } { $j < [string length $ga_code_bin] } { incr j } { binary scan $ga_code_bin @${j}H2 k set o [expr 0x$k ^ 0x5C] set i [expr 0x$k ^ 0x36] append ipad [format %c $i] append opad [format %c $o] } while { $j < 64 } { append ipad 6 append opad \\ incr j } binary scan [sha1 $opad[sha1 ${ipad}${time}]] H* token # end - HMAC-SHA1 calculation of Google Auth hex token # begin - extract code from Google Auth hex token set offset [expr ([scan [string index $token end] %x] & 0x0F) << 1] set ga_code_correct [expr (0x[string range $token $offset [expr $offset + 7]] & 0x7FFFFFFF) % 1000000] set ga_code_correct [format %06d $ga_code_correct] # end - extract code from Google Auth hex token if { $ga_code eq $ga_code_correct } { if { $static::logging >= 2 } { log local0. "$user ($user_ip): Google Authenticator TOTP token matched" } AUTH::username_credential $auth_id $user AUTH::password_credential $auth_id $pass AUTH::authenticate $auth_id HTTP::collect } else { if { $static::logging >= 1 } { log local0. "$user ($user_ip): authentication failed - Google Authenticator TOTP token not matched" } HTTP::respond 200 content $static::login_page } } else { if { $static::logging >= 1 } { log local0. "$user ($user_ip): could not find valid Google Authenticator secret for $user" } HTTP::respond 200 content $static::login_page } } else { if { $static::logging >= 1 } { log local0. "$user ($user_ip): attempting authentication too frequently, locking out for ${static::lockout_period}s" } HTTP::respond 200 content "You've made too many attempts too quickly. Please wait $static::lockout_period seconds and try again." } } else { HTTP::respond 200 content $static::login_page } } when AUTH_RESULT { if { [AUTH::status] == 0 } { set auth_status [AUTH::status $auth_id] set auth_id_aes [b64encode [AES::encrypt $static::auth_cookie_aes_key $auth_id]] table add -subtable $auth_status_table $auth_id $auth_status $static::auth_timeout $static::auth_lifetime table add -subtable $authid_to_user_table $auth_id $user $static::auth_timeout $static::auth_lifetime if { $static::logging >= 1 } { log local0. "$user ($user_ip): authentication successful (auth_id=$auth_id), redirecting to $orig_uri" } HTTP::respond 302 "Location" $orig_uri "Set-Cookie" "$static::auth_cookie=$auth_id_aes;" } else { if { $static::logging >= 1 } { log local0. "$user ($user_ip): authentication failed - invalid username or password" } HTTP::respond 200 content $static::login_page } }Authorization Required
Tested this on version:
10.0- Garry1DaviesNimbostratus
This works great once you figure out that the posted variables are passed in the payload URI encoded. Reserved characters are replaced with the hex encoded equivalents and thus passwords that contain these are never going to authenticate.
To fix this change the code below..
foreach param [split [HTTP::payload] &] { set [lindex [split $param =] 0] [lindex [split $param =] 1] }
to this
foreach param [split [HTTP::payload] &] { set [lindex [split $param =] 0] [URI::decode [lindex [split $param =] 1]] }
- BobDobNimbostratus
Hi George, et al, I'm looking to implement this but the problem is LTM doesn't support LDAP Authentication Profiles any more (only APM? refer https://devcentral.f5.com/s/feed/0D51T00006i7WmxSAE) so I'm at a bit of a loss as to how and where to apply this iRule.