If you have ever used HTTP Basic authentication in Apache extensively and then, for some reason, migrated to Nginx, you are probably missing the group based filter that Apache has for this functionality. I was using Apache till now, but recently had to shift to Nginx for performance reasons. And I found this feature missing in Nginx.
However, Nginx supports Lua, which is a programming language. And you can do pretty much anything with the power of Lua – it can be used to modify Nginxs behaviour in numerous ways. So I developed a simple solution for group based authentication in Lua.
Before I move on to the Nginx part, lets first recall how group based authentication was configured in Apache:
<Location /foo> AuthType Basic AuthName Restricted AuthBasicProvider file AuthUserFile /etc/apache2/htpasswd AuthGroupFile /etc/apache2/groups Require group somegroup </Location>
You are basically telling Apache to use the file /etc/apache2/htpasswd to look up user names and passwords and the group membership in /etc/apache2/groups. The htpasswd file usually consists of something like whats shown below:
foo:$apr1$2xICNyAr$rbGelq994Y3UKPhBoRwak. bar:$apr1$pRVQ9Z65$7XX98kYIpI/1nJik7jnSs1
And the group file consists of what follows:
somegroup1: foo somegroup2: bar
This method can be extended to LDAP/SQL authentication as well (using the appropriate Apache modules, like mod_authnz_ldap, mod_dbd and mod_authn_dbd). I wont go into the details of these in this article as there is sufficient documentation available online.
So lets say we write a Lua script named authenticate.lua. This is how you configure Nginx to execute the Lua script for checking access rights to a path /restricted:
location ~ ^/restricted { set $user_group somegroup; access_by_lua_file /etc/nginx/authenticate.lua; }
The Lua script to make it happen is given below:
-- basic configuration of the script local cookie_domain = .yourdomain.com local db_username = dbuser local db_password = dbpasswrod local db_socket = /tmp/mysql.sock local db_name = dbname -- end configuration local session = require resty.session.open{ cookie = { domain = cookie_domain } } local remote_password if ngx.var.http_authorization then local tmp = ngx.var.http_authorization tmp = tmp:sub(tmp:find( )+1) tmp = ngx.decode_base64(tmp) remote_password = tmp:sub(tmp:find(:)+1) end function authentication_prompt() session.data.valid_user = false session.data.user_group = nil session:save() ngx.header.www_authenticate = Basic realm=Restricted ngx.exit(401) end function authenticate(user, password, group) local mysql = require resty.mysql local db, err, errno, sqlstate, res, ok db = mysql:new() if not db then ngx.log(ngx.ERR, Failed to create mysql object) ngx.exit(500) end db:set_timeout(2000) ok, err, errno, sqlstate = db:connect{ path = db_socket, database = db_name, user = db_username, password = db_password } if not ok then ngx.log(ngx.ERR, Unable to connect to database: , err, : , errno, , sqlstate) ngx.exit(500) end user = ngx.quote_sql_str(user) password = ngx.quote_sql_str(password) local query = select 1 from http_users where username = %s and password = SHA2(%s, 224) and (find_in_set(superadmin, groups) > 0 or find_in_set(%s, groups) > 0) query = string.format(query, user, password, group); res, err, errno, sqlstate = db:query(query) if res and res[1] then session.data.valid_user = true session.data.user_group = group session:save() else authentication_prompt() end end if session.present and (session.data.valid_user and session.data.user_group == ngx.var.user_group) then return elseif ngx.var.remote_user and remote_password then authenticate(ngx.var.remote_user, remote_password, ngx.var.user_group) else authentication_prompt() end
The group authentication script looks for users and groups in a table called http_users. Since this is a script, you can modify the way users are searched for in the database or change the database altogether! The Lua modules required to run this script are resty.mysql, resty.session, resty.string and cjson. Though the passwords are stored in the database as a SHA224 hash, the comparison of the password is done by the database itself. I did not convert the password to hash before sending it to the database, so you could review this in case you are using a remote database as you may be sending passwords in clear text over the network! Im using a local database over a UNIX socket so it doesnt matter.
The database structure for this is as follows: CREATE TABLE `http_users` ( `username` varchar(15) NOT NULL DEFAULT , `password` varchar(56) NOT NULL DEFAULT , `groups` set(superadmin,group1,group2,group3,group3) NOT NULL DEFAULT , PRIMARY KEY (`username`), UNIQUE KEY `password` (`password`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1; DELIMITER $$ CREATE trigger http_user_ins before insert on http_users for each row begin SET NEW.password = SHA2(NEW.password, 224); end; DELIMITER $$ DELIMITER $$ CREATE trigger http_user_upd before update on http_users for each row begin if LENGTH(NEW.password) != 56 then SET NEW.password = SHA2(NEW.password, 224); end if; end; DELIMITER $$
The triggers are required to convert the INSERT or UPDATE statements into SHA224. Ive used MySQLs SET data type to ensure that the group value cannot contain errors – that is, it can only have a fixed set of values. This gets even better when you use PHPMyAdmin – it will give a list of check boxes for each of the group values. The same values can be used by Nginx in the $user_group variable before specifying the access_by_lua_file directive.
However, there is one problem with this method of securing access. This is that if you embed a location statement inside the location block in which you are calling the authentication script, as shown below (which happens to be a common pattern with modern PHP Web applications)
location ~ ^/restricted { set $user_group somegroup; access_by_lua_file /etc/nginx/authenticate.lua; location ~ ^(?<script_name>.+\.php)(?<path_info>/.*)?$ { try_files $script_name =404; fastcgi_pass unix:/var/run/php-fpm.sock; include fastcgi_params; } try_files $uri $uri/ /index.php$is_args$args; }
then the authentication process works only for the path /restricted; it doesnt work for the paths under the tree such as /restricted/a. I havent been able to find the exact reason to this but I have a naive fix for it:
local auth_groups = { [group1] = {/restricted}; [group2] = {/restricted2}; } local user_group for groupname, urls in pairs(auth_groups) do for _, url in pairs(urls) do if ngx.var.request_uri:find(url) then user_group = groupname break end end end
Run this piece of code after the basic configuration of the authentication script. This is naive because it does a linear (sequential) search in all the groups for every request. If you are going to use this on a large scale, a linear search can quickly eat up your CPU resources.
This piece of code changes the Nginx part of the configuration slightly. We were earlier sending $user_group from Nginx; now we dont have to, as the Lua script will verify the URL to group mapping, as follows:
location /phpMyAdmin { root /usr/local/www; access_by_lua_file authenticate.lua; location ~ ^(?<script_name>.+\.php)(?<path_info>/.*)?$ { try_files $script_name =404; fastcgi_pass unix:/var/run/php-fpm-www.sock; include fastcgi_params; } }
The authentication script is available on my GitHub account: https://github.com/nileshgr/utilities/blob/master/admin/nginx-group-authenticate.lua.
[…] this and use the old algorithm of HTTP. Old HTTP is not quite effective these days as there are a lot of ways to work things around it. However, HTTPs is still a very secure and gives you the privacy to hide your […]