APM Sharepoint authentication
Problem this snippet solves:
Updated version to support Webdav with windows explorer after Nicolas's comment.
APM is a great authentication service but it does it only with forms.
The default behavior is to redirect user to /my.policy to process VPE. this redirect is only supported for GET method.
Sharepoint provide 3 different access types:
- browsing web site with a browser
- Editing documents with Office
- browser folder with webdav client (or editing documents with libreoffice through webdav protocol)
This irule display best authentication method for each of these access types:
- browsers authenticate with default authentication method (form based authentication)
- Microsoft office authenticate with Form based authentication (with support of MS-OFBA protocol)
- Libreoffice and webdav clients authenticate with 401 basic authentication
Form based authentication (browser and Microsoft office) is compatible (validated for one customer) with SAML authentication
Editing documents is managed with a persistent cookie expiring after 5 minutes. to be shared between IE and Office, it requires :
- cookie is persistent (expiration date instead of deleted at the end of session)
- web site defined as "trusted sites" in IE.
How to use this snippet:
install this irule and enable it on the VS.
Code :
when RULE_INIT { array set static::MSOFBA { ReqHeader "X-FORMS_BASED_AUTH_REQUIRED" ReqVal "/sp-ofba-form" ReturnHeader "X-FORMS_BASED_AUTH_RETURN_URL" ReturnVal "/sp-ofba-completed" SizeHeader "X-FORMS_BASED_AUTH_DIALOG_SIZE" SizeVal "800x600" } set static::ckname "MRHSession_SP" set static::Basic_Realm_Text "SharePoint Authentication" } when HTTP_REQUEST { set apmsessionid [HTTP::cookie value MRHSession] set persist_cookie [HTTP::cookie value $static::ckname] set clientless_mode 0 set form_mode 0 # Identify User-Agents type if {[HTTP::header exists "X-FORMS_BASED_AUTH_ACCEPTED"] && (([HTTP::header "X-FORMS_BASED_AUTH_ACCEPTED"] equals "t") || ([HTTP::header "X-FORMS_BASED_AUTH_ACCEPTED"] equals "f"))} { set clientless_mode 0; set form_mode 1 } else { switch -glob [string tolower [HTTP::header "User-Agent"]] { "*microsoft-webdav-miniredir*" { set clientless_mode 1 } "*microsoft data access internet publishing provider*" - "*office protocol discovery*" - "*microsoft office*" - "*non-browser*" - "msoffice 12*" { set form_mode 1 } "*mozilla/4.0 (compatible; ms frontpage*" { if { [ string range [getfield [string tolower [HTTP::header "User-Agent"]] "MS FrontPage " 2] 0 1] > 12 } { set form_mode 1 } else { set clientless_mode 1 } } "*mozilla*" - "*opera*" { set clientless_mode 0 } default { set clientless_mode 1 } } } if { $clientless_mode || $form_mode } { if { [HTTP::cookie exists "MRHSession"] } {set apmstatus [ACCESS::session exists -state_allow $apmsessionid]} else {set apmstatus 0} if { !($apmstatus) && [HTTP::cookie exists $static::ckname] } {set apmpersiststatus [ACCESS::session exists -state_allow $persist_cookie]} else {set apmpersiststatus 0} if { ($apmpersiststatus) && !($apmstatus) } { # Add MRHSession cookie for non browser user-agent first request and persistent cookie present if { [catch {HTTP::cookie insert name "MRHSession" value $persist_cookie} ] } {log local0. "[IP::client_addr]:[TCP::client_port] : TCL error on HTTP cookie insert MRHSession : URL : [HTTP::host][HTTP::path] - Headers : [HTTP::request]"} else {return} } } else { return } if { $clientless_mode && !($apmstatus)} { if { !([HTTP::header Authorization] == "") } { set clientless(insert_mode) 1 set clientless(username) [ string tolower [HTTP::username] ] set clientless(password) [HTTP::password] binary scan [md5 "$clientless(password)"] H* clientless(hash) set user_key "$clientless(username).$clientless(hash)" set clientless(cookie_list) [ ACCESS::user getsid $user_key ] if { [ llength $clientless(cookie_list) ] != 0 } { set clientless(cookie) [ ACCESS::user getkey [ lindex $clientless(cookie_list) 0 ] ] if { $clientless(cookie) != "" } { HTTP::cookie insert name MRHSession value $clientless(cookie) set clientless(insert_mode) 0 } } if { $clientless(insert_mode) } { HTTP::header insert "clientless-mode" 1 HTTP::header insert "username" $clientless(username) HTTP::header insert "password" $clientless(password) } unset clientless } else { HTTP::respond 401 WWW-Authenticate "Basic realm=\"$static::Basic_Realm_Text\"" Set-Cookie "MRHSession=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/" Connection close return } } elseif {$form_mode && !($apmstatus) && !([HTTP::path] equals $static::MSOFBA(ReqVal))}{ HTTP::respond 403 -version "1.1" noserver \ $static::MSOFBA(ReqHeader) "https://[HTTP::host]$static::MSOFBA(ReqVal)" \ $static::MSOFBA(ReturnHeader) "https://[HTTP::host]$static::MSOFBA(ReturnVal)" \ $static::MSOFBA(SizeHeader) $static::MSOFBA(SizeVal) \ "Connection" "Close" return } } when HTTP_RESPONSE { # Insert persistent cookie for html content type and private session if { [HTTP::header "Content-Type" ] contains "text/html" } { HTTP::cookie remove $static::ckname HTTP::cookie insert name $static::ckname value $apmsessionid path "/" HTTP::cookie expires $static::ckname 120 relative HTTP::cookie secure $static::ckname enable } # Insert session cookie if session was recovered from persistent cookie if { ([info exists "apmpersiststatus"]) && ($apmpersiststatus) } { HTTP::cookie insert name MRHSession value $persist_cookie path "/" HTTP::cookie secure MRHSession enable } } when ACCESS_SESSION_STARTED { if {([info exists "clientless_mode"])} { ACCESS::session data set session.clientless $clientless_mode } if { [ info exists user_key ] } { ACCESS::session data set "session.user.uuid" $user_key } } when ACCESS_POLICY_COMPLETED { if { ([info exists "clientless_mode"]) && ($clientless_mode) && ([ACCESS::policy result] equals "deny") } { ACCESS::respond 401 noserver WWW-Authenticate "Basic realm=$static::Basic_Realm_Text" Connection close ACCESS::session remove } } when ACCESS_ACL_ALLOWED { switch -glob [string tolower [HTTP::path]] { "/sp-ofba-form" { ACCESS::respond 302 noserver Location "https://[HTTP::host]$static::MSOFBA(ReturnVal)" } "/sp-ofba-completed" { ACCESS::respond 200 content {Authenticated Good Work, you are Authenticated } noserver } "*/signout.aspx" { # Disconnect session and redirect to APM logout Page ACCESS::respond 302 noserver Location "/vdesk/hangup.php3" return } "/_layouts/accessdenied.aspx" { # Disconnect session and redirect to APM Logon Page if {[string tolower [URI::query [HTTP::uri] loginasanotheruser]] equals "true" } { ACCESS::session remove ACCESS::respond 302 noserver Location "/" return } } default { # No Actions } } }
Tested this on version:
11.5- Stanislas_Piro2Cumulonimbus
since version 11.4, you can use localdb instance to store lockout users instead of table.
localdb instance allow to dynamic user creation. in locadb write action, you can allow to create unknown users.
follow this link to configure it:
about your question on tcl version 8.5 since TMOS 12.0, there is nothing to do. According to SOL36322151, irule use TCL 8.6.7 since version 12.0
Hi Stanislas,
you can't use locadb to secure the usage of
within HTTP_REQUEST. Its only available within an APM Policy which is to late in the chain. Even then i think you will get much better results using iRules in combination with[ACCESS::user getsid $user_key]
.[table]
Furthermore, my aged development strongbox is running on TMOS 12.0.0 with TCL version 8.4.6. Specifc 8.5 commands are not shown using
nor[info commands]
comamnds. Also any new 8.5 syntaxes (e.g.[interp hidden]
aka. nipple expand) are not available and the performance of{*}
is still suboptimal.[info exists]
You may try the code below on 12.1 to see if TCL8.5 is available on those plattforms. If not, then please open a ticket for me 🙂
when RULE_INIT { log local0.debug "TCL version : [info patchlevel]" }
Cheers, Kai
- Stanislas_Piro2Cumulonimbus
OK,
I did not see what did your irule. I now understand what you mean.
I think usage of
with password hash is as secure as usage of session cookie.[ACCESS::user getsid $user_key]
there is the same security issue if the account is locked after the user signed on logon page.
the irule does not use username and password to authenticate but as a fingerprint to be sure this is the same user as the previous session, and reuse the same session, like session cookie does.
that's why I was thinking about insert client ip in the fingerprint as in exchange irule.
Hi Stanislas,
without additional Account Lockouts inplace you will simply allow attacker to bypass the account lockouts of your internal repository. An IP binding (like implemented in the EAS iRules) will make it just a little harder and introduces certain side effects (opening multiple APM sessions if an IP change occours). But its by any means not a secure solution^^
Cheers, Kai
Hi Stanislas,
if you're going to implement an account lockout then make sure you issue
before sending the lockout 401 response. Otherwise it will be possible for an attacker to detect that the lockout is active.after "[PROFILE::access min_failure_delay]000"
Cheers, Kai
- Stanislas_Piro2Cumulonimbus
Hi Kai,
[ACCESS::user getsid $user_key] in only used when another session is already authenticated. so the user provided first the right login / password.
for next requests with same login / password, there is no need to check if the password is wrong with lockout prevention. the password was right during first logon, so password is used to build fingerprint.
I understand that brute force prevention may be the next security part for the irule allowing basic auth as APM disable brute force prevention with min / max failure delay.
to enable min / max failure delay, I can add the following code before ACCESS::respond in ACCESS_POLICY_COMPLETED event:
set min "[PROFILE::access min_failure_delay]000" set max "[PROFILE::access max_failure_delay]000" after [expr {int(rand() * ($max + 1 - $min)) + $min}] ACCESS::respond 401 noserver WWW-Authenticate "Basic realm=\"$static::Basic_Realm_Text\"" Connection close unset min max
Hi Stanislas,
for next requests with same login / password, there is no need to check if the password is wrong with lockout prevention. the password was right during first logon, so password is used to build fingerprint.
To protect against bruteforce attacks, its required to block further authentication attemps using the same username if a certain threshold of wrong logins has been reached. If caches are deployed to offload repository authentication (e.g.
is a credential cache!), then you MUST either make sure that the lockout mechanism will also cover access to the cached credentials, or you MUST make sure that the cached credentials of a given username are gettings invalidated once the account lockout is active.[ACCESS::user getsid $user_key]
Since invalidating the cache is not practical in our scenario (it will cause the existing session to become removed), its wise to enforce the lockout in front of the cache. If both methods are ignored, the caches can be used to bruteforce a currently active account, even in the case the repository has already enforced a lockout...
after [expr {int(rand() * ($max + 1 - $min)) + $min}]
Yours is even better.. 😉
Cheers, Kai
- Stanislas_Piro2Cumulonimbus
Hi Kai,
I followed all your advices (except lockout, One solution instead of blocking with table can be deleting in ACCESS_POLICY_COMPLETED sessions of user with same username if VPE lockout occurs)
here is the new version :
when RULE_INIT { set static::Basic_Realm_Text "SharePoint Authentication" } when CLIENT_ACCEPTED { set inject_session_cookie 0 set last_ua_agent "init" } when HTTP_REQUEST { if { ! [ info exists SP_PROFILE_RESTRICT_SINGLE_IP ] } { set SP_PROFILE_RESTRICT_SINGLE_IP [PROFILE::access restrict_to_single_client_ip] } Identify User-Agents type if { $last_ua_agent equals [set last_ua_agent [HTTP::header value "User-Agent"]] } { Do nothing, keep previous request authschema value log local0. $last_ua_agent } elseif {[HTTP::header exists "X-FORMS_BASED_AUTH_ACCEPTED"] && (([HTTP::header "X-FORMS_BASED_AUTH_ACCEPTED"] equals "t") || ([HTTP::header "X-FORMS_BASED_AUTH_ACCEPTED"] equals "f"))} { set clientless_mode 0; set form_mode 1 } else { set clientless_mode 0 set form_mode 0 switch -glob -- [string tolower [HTTP::header "User-Agent"]] "*office protocol discovery*" - \ "*microsoft office*" - \ "*microsoft data access internet publishing provider*" - \ "*non-browser*" - \ "msoffice 12*" - \ "*microsoft-webdav-miniredir*" - \ {*ms frontpage 1[23456789]*} { Implicit MSOFBA support detected. set form_mode 1 } "*ms frontpage*" { Legacy client detected set clientless_mode 1 } "*mozilla*" - \ "*opera*" { Regular web browser detected. set clientless_mode 0 } default { set clientless_mode 1 } } if { $clientless_mode || $form_mode } { if { ( [set sessionid [HTTP::cookie value "MRHSession"]] ne "" ) and ( [ACCESS::session exists -state_allow $sessionid] ) } then { Allow the successfully pre authenticated request to pass return } else { Check if persistent APM session cookie is present and valid if { ( [set sessionid [HTTP::cookie value "MRHSession_SP"]] ne "" ) and ( [ACCESS::session exists -state_allow $sessionid] ) } then { Restore APM session cookie value if { [catch {HTTP::cookie insert name "MRHSession" value $sessionid} ] } {log local0. "[IP::client_addr]:[TCP::client_port] : TCL error on HTTP cookie insert MRHSession : URL : [HTTP::host][HTTP::path] - Headers : [HTTP::request]"} else {return} HTTP::cookie insert name "MRHSession" value $sessionid set inject_session_cookie 1 Allow the successfully pre authenticated request to pass return } } } else { set sessionid [HTTP::cookie value "MRHSession"] return } if { $clientless_mode } { if { [ string match -nocase {basic *} [HTTP::header Authorization] ] == 1 } { set clientless(insert_mode) 1 set clientless(src_ip) [IP::remote_addr] set clientless(username) [ string tolower [HTTP::username] ] set clientless(password) [HTTP::password] if { $SP_PROFILE_RESTRICT_SINGLE_IP == 0 } { binary scan [md5 "$clientless(password)"] H* clientless(hash) } else { binary scan [md5 "$clientless(password)$clientless(src_ip)"] H* clientless(hash) } set user_key "$clientless(username).$clientless(hash)" set clientless(cookie_list) [ ACCESS::user getsid $user_key ] if { [ llength $clientless(cookie_list) ] != 0 } { set clientless(cookie) [ ACCESS::user getkey [ lindex $clientless(cookie_list) 0 ] ] if { $clientless(cookie) != "" } { HTTP::cookie insert name MRHSession value $clientless(cookie) set clientless(insert_mode) 0 } } if { $clientless(insert_mode) } { HTTP::header insert "clientless-mode" 1 HTTP::header insert "username" $clientless(username) HTTP::header insert "password" $clientless(password) } unset clientless } else { HTTP::respond 401 noserver WWW-Authenticate "Basic realm=\"$static::Basic_Realm_Text\"" Set-Cookie "MRHSession=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/" Connection close return } } elseif {$form_mode && [HTTP::path] ne "/sp-msofba-form"}{ HTTP::respond 403 -version "1.1" \ content "Access Denied. Make sure that your client is correctly configured. See https://support.microsoft.com/en-us/kb/932118 for further information." \ noserver \ "Content-Type" "text/html" \ "X-FORMS_BASED_AUTH_REQUIRED" "https://[getfield [HTTP::host] ":" 1]/sp-msofba-form" \ "X-FORMS_BASED_AUTH_RETURN_URL" "https://[getfield [HTTP::host] ":" 1]/sp-msofba-completed" \ "X-FORMS_BASED_AUTH_DIALOG_SIZE" "800x600" \ "Set-Cookie" "MRHSession=deleted;path=/;secure" \ "Set-Cookie" "LastMRH_Session=deleted;path=/;secure" \ "Set-Cookie" "MRHSession=deleted; expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;secure" \ "Set-Cookie" "LastMRH_Session=deleted; expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;secure" return } } when HTTP_RESPONSE { Insert persistent cookie for html content type and private session if { [HTTP::header "Content-Type" ] contains "text/html" } { HTTP::cookie remove MRHSession_SP HTTP::cookie insert name MRHSession_SP value $sessionid path "/" HTTP::cookie expires MRHSession_SP 120 relative HTTP::cookie secure MRHSession_SP enable } Insert session cookie if session was recovered from persistent cookie if { ([info exists "inject_session_cookie"]) && ($inject_session_cookie) } { HTTP::cookie insert name MRHSession value $sessionid path "/" HTTP::cookie secure MRHSession enable } } when ACCESS_SESSION_STARTED { if {([info exists "clientless_mode"])} { ACCESS::session data set session.clientless $clientless_mode } if { [ info exists user_key ] } { ACCESS::session data set "session.user.uuid" $user_key } } when ACCESS_POLICY_COMPLETED { if { ([info exists "clientless_mode"]) && ($clientless_mode) && ([ACCESS::policy result] equals "deny") } { set min "[PROFILE::access min_failure_delay]000" set max "[PROFILE::access max_failure_delay]000" after [expr {int(rand() * ($max + 1 - $min)) + $min}] ACCESS::respond 401 noserver WWW-Authenticate "Basic realm=\"$static::Basic_Realm_Text\"" Connection close unset min max ACCESS::session remove } } when ACCESS_ACL_ALLOWED { switch -glob [string tolower [HTTP::path]] { "/sp-msofba-form" { ACCESS::respond 302 noserver Location "/sp-msofba-completed" } "/sp-msofba-completed" { ACCESS::respond 200 content { Authenticated Good Work, you are Authenticated } noserver } "*/signout.aspx" { Disconnect session and redirect to APM logout Page ACCESS::respond 302 noserver Location "/vdesk/hangup.php3" return } "/_layouts/accessdenied.aspx" { Disconnect session and redirect to APM Logon Page if {[string tolower [URI::query [HTTP::uri] loginasanotheruser]] equals "true" } { ACCESS::session remove ACCESS::respond 302 noserver Location "/" return } } default { No Actions } } }
Hi Stanislas,
I have some feedback for you...
Line 11-13: Make this option a static::variable. Line 15: Unify the syntax within your script to either [HTTP::header MyHeader] or [HTTP::header value MyHeader]. Line 17: Leftover log line? Line 18: The HTTP::header exists makes much sense. I've updated my own script to include this. Line 19: I don't see a reason to use two independent variables for forms and client_less. Using a multivalue would make things easier. You may use 0 for browser, 1 for MSOFBA and 2 for Clientless. In this case you could apply certain script blocks for ==1 ==2 or even >0 Line 20,21: Make sure that each switch script block sets both variables. In this case you don't need set them to 0 at the beginning. It will safe some cycles. Line 51: Remove this line. HTTP::header insert should not throw an error and line 52 will nevertheless insert the cookie for you. Line 53: Keep in mind that you should reset this variable once a cookie has been issued. Currently it will issues a cookie on consecutive requests. Alternatively set this variable on each request depending on the enumerated client type. Line 68: Remove the == 0 and flip the order of the [if]. It may save your some additional cycles.... Line 111: I would make sure that only browser are allowed to issue persistent cookie. I guess there is no reason to inject persistent cookie for non-browser clients. Line 112: A SharePoint wouldn't use a cookie name of MRHSession_SP, so you don't need to remove it. Line 113-114: Combine this two commands using a HTTP::header insert it will safe some cycles. Also try to issue a HttpOnly header. It will work with Office clients. Line 135-137: There is no need to random-delay the 401 within ACCESS_POLICY_COMPLETED. Its already auto-random-delayed. The delay would be required only for 401 responses within the HTTP_REQUEST event. Line 118: There is no need for the [info exists]. The variable is set during CLIENT_ACCEPTED and the not removed. Deleting VPE session: Sounds interesting. How will you enumerate the sssions with an identical username?
Cheers, Kai
- Stanislas_Piro2Cumulonimbus
Hi Kai,
here are my answers:
- Line 11-13: Make this option a static::variable. : No, if it is static::variable, it won't change when changing Access profile since the irule is reload (static::variable goal is to be set in RULE_INIT).
- Line 15: Unify the syntax within your script to either [HTTP::header MyHeader] or [HTTP::header value MyHeader]. : I agree. I will change it.
- Line 17: Leftover log line? : debug log i forgot to remove
- Line 18: The HTTP::header exists makes much sense. I've updated my own script to include this.
- Line 19: I don't see a reason to use two independent variables for forms and client_less. Using a multivalue would make things easier. You may use 0 for browser, 1 for MSOFBA and 2 for Clientless. In this case you could apply certain script blocks for ==1 ==2 or even >0 : I agree, but I must change it and manage in ACCESS_SESSION_STARTED, time was missing
- Line 20,21: Make sure that each switch script block sets both variables. In this case you don't need set them to 0 at the beginning. It will safe some cycles. I will change to one variable
- Line 51: Remove this line. HTTP::header insert should not throw an error and line 52 will nevertheless insert the cookie for you. Cookie insert,as most of HTTP commands, can throw an error if previous HTTP response command was executed before (another irule or LTM Policy)
- Line 53: Keep in mind that you should reset this variable once a cookie has been issued. Currently it will issues a cookie on consecutive requests. Alternatively set this variable on each request depending on the enumerated client type.
- Line 68: Remove the == 0 and flip the order of the [if]. It may save your some additional cycles.... : OK, I got it from F5 exchange irule
- Line 111: I would make sure that only browser are allowed to issue persistent cookie. I guess there is no reason to inject persistent cookie for non-browser clients.
- Line 112: A SharePoint wouldn't use a cookie name of MRHSession_SP, so you don't need to remove it. : Right
- Line 113-114: Combine this two commands using a HTTP::header insert it will safe some cycles. Also try to issue a HttpOnly header. It will work with Office clients. I agree, I will do it
- Line 135-137: There is no need to random-delay the 401 within ACCESS_POLICY_COMPLETED. Its already auto-random-delayed. The delay would be required only for 401 responses within the HTTP_REQUEST event. I tested it an in clientless mode and the auto random delay was not working on version 12.0, I will try again
- Line 118: There is no need for the [info exists]. The variable is set during CLIENT_ACCEPTED and the not removed. : Right
And Lines 100 - 101: same cookie remove than lines 102 - 103. lines 100 and 101 will be removed.