├── LICENSE.txt ├── README.md ├── docs ├── api.md ├── saml.md └── u2f.md ├── external ├── raven.lua └── resty │ └── cookie.lua ├── nginx ├── sso-init.conf ├── sso-site.conf └── upstream.conf ├── pages ├── README.md ├── auth │ └── index.html └── resources │ ├── .gitignore │ ├── bower.json │ └── summit-min.css └── src ├── access.lua ├── api.lua ├── config.lua ├── init.lua ├── session.lua └── util.lua /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Sean Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | lsso 2 | ===== 3 | 4 | lsso is a SSO middleware written in Lua to sit between Nginx and server endpoints. 5 | 6 | lsso uses client-side cookies alongside a Redis database of session hashes to track session. 7 | In our setup, we use a fork of [Osiris](https://github.com/pirogoeth/osiris) with a Redis token store as an OAuth endpoint. 8 | 9 | Features: 10 | - OAuth authentication 11 | - Raven / Sentry support 12 | - Cross-domain-authentication 13 | - Backend session store in Redis 14 | - Auth and session event logging to Redis 15 | - CLI management tool, [lssoctl](https://github.com/maiome-development/lssoctl) (*In Progress!*) 16 | - Management API (*In Progress!*) 17 | - Temporary access token generation 18 | - 2FA Support 19 | 20 | Requirements 21 | ============ 22 | 23 | - Lua51 (nginx-lua requirement) 24 | - LuaSec >= 0.5 25 | - Raven-Lua (modified version included in external/raven.lua; includes HTTPS support for Sentry) 26 | - Nginx-resty-cookie (included in external/resty/) 27 | - lua-cjson (https://github.com/efelix/lua-cjson) 28 | - redis-lua (https://github.com/nrk/redis-lua) 29 | - OAuth server (recommended: https://github.com/pirogoeth/osiris; has been tested) 30 | - authy-lua (required for authy-2fa; pkg: authy-lua >= 0.1.0-4, src: https://github.com/pirogoeth/authy-lua) 31 | - lua-resty-http (required for authy-2fa; pkg: lua-resty-http 0.07-0, src: https://github.com/pintsized/lua-resty-http) 32 | 33 | Installation 34 | ============= 35 | 36 | - Clone this repo.. 37 | - Copy external/\* to your lua5.1 package dir (/usr/local/share/lua/5.1/ or similar) 38 | - Use the file from `nginx/sso-init.conf` to set up the main nginx conf. 39 | - Make sure to adjust the request rate limit to your desire. 40 | - Use the template from `nginx/sso-site.conf` to set up your SSO endpoint. 41 | - Adjust any endpoints as you wish, but make sure to update `config.lua` as well. 42 | - Grab the src/config.lua, configure it, and stick it where you want 43 | - Change `config_path` in src/init.lua to point to your newly configured config.lua. 44 | - Insert `access_by_lua_file /path/to/lsso/src/access.lua;` in any location, server block, etc, that you want to protect. 45 | - Restart nginx. 46 | - Done! (?) 47 | 48 | 49 | Roadmap 50 | ======= 51 | 52 | - Authentication: 53 | - [ ] HTTP Basic authentication support for endpoints. 54 | - _Stage_: Researching 55 | - [ ] Implement SAML 2.0 authentication 56 | - _Stage_: Researching & implementing 57 | - [ ] Implement U2F Registration / Authentication process 58 | - _Stage_: Researching 59 | - [ ] Use JWT cookie instead of unsigned client cookies (? | [lua-resty-jwt](https://github.com/SkyLothar/lua-resty-jwt)) 60 | - _Stage_: Researching 61 | - [X] Per-location auth scoping (customizable scopes for each protected location: `set $lsso_location_scope 'admin';` before `access_by_lua_file`) 62 | - API: 63 | - [ ] API access tokens 64 | - Inherently different from regular access tokens, but possibly managed/requested through the same endpoint? 65 | - If using a different endpoint, possibly `/api/auth` (?). 66 | - [ ] Some user-facing endpoints for managing sessions: 67 | - [ ] /auth/logout - kill the active user session, if any. 68 | - [ ] API for token requests, management, health, etc. 69 | - [X] /api/\_health - simple status 70 | - [X] /api/token/request - request access token 71 | - [X] Log access endpoints 72 | - [X] /log/api - api event log 73 | - [X] /log/auth - authentication event log 74 | - [X] /log/session - session event log 75 | - ... 76 | - ... 77 | - Metadata: 78 | - [ ] Metadata store implementation 79 | - Required for U2F and other 2FA implementations 80 | - Should be an ephemeral data store, possibly key-value or record-based 81 | - Implementation language does not need to be Lua... 82 | - Should be simplistic, have an HTTP API, HTTP client 83 | - Should *not* depend on a temporal data store such as Redis (unless configured as persistent store) 84 | - _Stage_: Researching 85 | - Miscellaneous: 86 | - [ ] More documentation! 87 | - [ ] Stats collection for info about user sessions, login attempts, page accesses (?) 88 | - [ ] Stats export via statsd for aggregation (?) 89 | - [ ] Status portal (with *content_by_lua_file* and [lustache](https://github.com/Olivine-Labs/lustache)) 90 | - Multi-Factor Auth: 91 | - [ ] Implement base for 2FA... 92 | - Major 2FA types: 93 | - [ ] Authy 94 | - _Stage_: Researching & implementation 95 | - [ ] U2F 96 | - _Stage_: Researching 97 | 98 | Contributing 99 | ============ 100 | 101 | Pull requests and issues are more than welcome! I need as much feedback on this as possible to continue improving the SSO. 102 | 103 | To discuss code or anything else, you can find us on IRC at irc.maio.me in #dev. 104 | 105 | 106 | Licensing 107 | ========= 108 | 109 | This project is licensed under the MIT License. You can view the full terms of the license in `/LICENSE.txt`. 110 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | API Endpoint Docs & Roadmap 2 | =========================== 3 | 4 | #### Implementation 5 | The LSSO API is implemented as a separate Lua source file in the `src` directory. 6 | For now, endpoints are simply matched without any crazy routing handler, although that would be a nice way to go in the future. 7 | 8 | So far, there are three API endpoints exposed: 9 | - `/_health` 10 | - `/token/request` 11 | - `/log/:bucket` 12 | 13 | ##### `/_health` 14 | The `/_health` API endpoint serves a simple purpose: to return the string `"okay"`. 15 | This endpoint *does not* require authentication. 16 | 17 | ##### `/token/request` 18 | The `/token/request` endpoint allows a user to programmatically request an SSO access token, which can then be used by an external user to obtain a delegated session token for protected site access. This can possibly be a security vulnerability if the access token enters the wrong hands, but some precautions are taken to ensure that damage is limited: 19 | - Tokens only live for the number of hours specified by `config.cookie_lifetime`, at maximum. 20 | - Once a token is used to retrieve a delegated session, the token itself is destroyed and the session is sent to the requesting user. 21 | - Access tokens can be scoped manually, unlike regular requests, which may compound scopes as escalation is needed. This way, the delegated user has access to only scopes the delegating user provides. Because of the token lifetime, if a scope elevation point is reached, the access token will not be able to be re-used to authenticate and normal authentication will be required. 22 | 23 | Endpoint Details: 24 | 25 | HTTP Method: `POST` 26 | 27 | Parameters: 28 | - `username` -> OAuth Username to identify as 29 | - `password` -> OAuth Password for above username 30 | - `expire` -> Seconds until expiry (optional; defaults to `config.cookie_lifetime`) 31 | - `scope` -> Space-separated list of scopes to assign to the token (optional; defaults to `config.oauth_auth_scope`) 32 | 33 | Returns: 34 | - JSON Dictionary indicating status, errors, and response data: 35 | 36 | Failure: 37 | 38 | ``` 39 | { 40 | "code": 400, 41 | "message": "Missing `username` field" 42 | } 43 | ``` 44 | 45 | Success: 46 | 47 | ``` 48 | { 49 | "code": 200, 50 | "message": "Access token created", 51 | "token": "", 52 | "expires": "", 53 | "username": "" 54 | } 55 | ``` 56 | 57 | Return Codes: 58 | 59 | Codes are returned through the `code` field on the JSON response. There are several possible values, each with a more detailed error message. 60 | 61 | `.code = 400`: 62 | - Missing `username` or `password` field 63 | 64 | `.code = 200`: 65 | - Access token created successfully. 66 | 67 | `.code = *`: 68 | - Any other code comes from the upstream OAuth server when an error occurs. 69 | 70 | ##### `/log/:bucket` 71 | The `/log/:bucket` endpoint allows an administrator to dump logs from the Redis backend via a remote call. By default, there is no authentication enforcement imposed on this endpoint, so it is recommended that it be protected (as is demonstrated in `nginx/sso-site.conf`) by some authentication. Available log buckets are `api`, `auth`, and `session` 72 | 73 | **NOTE**: Should you choose to guard the logging endpoint with LSSO itself, know that there is no HTTP basic authentication support in the codebase yet, so this endpoint would only be realistically usable from a browser. 74 | 75 | Endpoint Details: 76 | 77 | HTTP Method: `GET` 78 | 79 | Parameters: 80 | - `:bucket:` - [URI] - Must be one of ("api" "auth" "session") 81 | - `page` - [QS] - Useful for pagination. 82 | - `limit` - [QS] - Number of items to show per-page (optional; defaults to `config.log_paginate_count`) 83 | 84 | Returns: 85 | 86 | - JSON dictionary with status, pagination info, and response data. 87 | 88 | Failure: 89 | 90 | ``` 91 | { 92 | "code": 404, 93 | "message": "Requested log bucket does not exist." 94 | } 95 | ``` 96 | 97 | Success: 98 | 99 | ``` 100 | { 101 | "code": 200, 102 | "message": "okay", 103 | "pagination": { 104 | "page": 0, 105 | "limit": 20 106 | }, 107 | "response": [...] 108 | } 109 | ``` 110 | 111 | Return Values: 112 | 113 | `.code = 404`: 114 | - Log bucket was not found. 115 | 116 | `.code = 200`: 117 | - Log bucket was found and fetched. 118 | -------------------------------------------------------------------------------- /docs/saml.md: -------------------------------------------------------------------------------- 1 | SAML 2.0 Authentication Docs & Roadmap 2 | ====================================== 3 | 4 | In progress... 5 | -------------------------------------------------------------------------------- /docs/u2f.md: -------------------------------------------------------------------------------- 1 | U2F Authentication Docs & Roadmap 2 | ================================= 3 | 4 | In progress... 5 | -------------------------------------------------------------------------------- /external/raven.lua: -------------------------------------------------------------------------------- 1 | ------------------------------------------------------------------- 2 | -- raven.lua: a Lua Raven client used to send errors to 3 | -- Sentry 4 | -- 5 | -- According to client development guide 6 | -- 7 | -- The following items are expected of production-ready clients: 8 | --
    9 | --
  • DSN configuration √
  • 10 | --
  • Graceful failures (e.g. Sentry server unreachable) √
  • 11 | --
  • Scrubbing w/ processors
  • 12 | --
  • Tag support √
  • 13 | --
14 | -- 15 | -- To test a DSN configuration: 16 | --
$ lua raven.lua test [DSN]
17 | -- 18 | -- @author JGC 19 | -- @author Jiale Zhi 20 | -- @copyright (c) 2013-2014, CloudFlare, Inc. 21 | -------------------------------------------------------------------- 22 | --pcall(require("luacov")) 23 | local json = require("cjson") 24 | local debug = require("debug") 25 | 26 | local ngx = ngx 27 | local arg = arg 28 | local setmetatable = setmetatable 29 | local tostring = tostring 30 | local xpcall = xpcall 31 | 32 | local os_date = os.date 33 | local os_time = os.time 34 | local debug_getinfo = debug.getinfo 35 | local math_random = math.random 36 | local json_encode = json.encode 37 | local string_format = string.format 38 | local string_match = string.match 39 | local string_find = string.find 40 | local string_sub = string.sub 41 | local table_insert = table.insert 42 | 43 | local debug = false 44 | 45 | local socket 46 | local ssl 47 | local catcher_trace_level = 4 48 | if not ngx then 49 | local ok, luasocket = pcall(require, "socket") 50 | if not ok then 51 | error("No socket library found, you need ngx.socket or luasocket.") 52 | end 53 | local ok, luassl = pcall(require, "ssl") 54 | if ok then 55 | ssl = luassl 56 | end 57 | socket = luasocket 58 | else 59 | socket = ngx.socket 60 | end 61 | 62 | local ok, new_tab = pcall(require, "table.new") 63 | if not ok then 64 | new_tab = function (narr, nrec) return {} end 65 | end 66 | 67 | local ok, clear_tab = pcall(require, "table.clear") 68 | if not ok then 69 | clear_tab = function(tab) 70 | for k, v in pairs(tab) do 71 | tab[k] = nil 72 | end 73 | end 74 | end 75 | 76 | local function log(...) 77 | if not ngx then 78 | print(...) 79 | else 80 | ngx.log(ngx.NOTICE, ...) 81 | end 82 | end 83 | 84 | -- backup logging when cannot send data to Sentry 85 | local function errlog(...) 86 | if not ngx then 87 | print("[ERROR]", ...) 88 | else 89 | ngx.log(ngx.ERR, ...) 90 | end 91 | end 92 | 93 | local _json = {} 94 | 95 | local _exception = { {} } 96 | 97 | local _M = {} 98 | 99 | local mt = { 100 | __index = _M, 101 | } 102 | 103 | math.randomseed(os_time()) 104 | 105 | -- hexrandom: returns a random number in hex with the specified number 106 | -- of digits 107 | local function hexrandom(digits) 108 | local s = '' 109 | for i=1,digits do 110 | s = s .. string_format("%0x", math_random(1,16)-1) 111 | end 112 | return s 113 | end 114 | 115 | -- uuid4: create a UUID in Version 4 format as a string albeit without 116 | -- the -s separating fields 117 | local function uuid4() 118 | return string_format("%s4%s8%s%s", hexrandom(12), hexrandom(3), 119 | hexrandom(3), hexrandom(12)) 120 | end 121 | 122 | -- iso8601: returns the current date/time in ISO8601 format with no 123 | -- timezone indicator but in UTC 124 | local function iso8601() 125 | 126 | -- The ! forces os_date to return UTC. Don't change this to use 127 | -- os.date/os.time to format the date/time because timezone 128 | -- problems occur 129 | 130 | local t = os_date("!*t") 131 | return string_format("%04d-%02d-%02dT%02d:%02d:%02d", 132 | t["year"], t["month"], t["day"], t["hour"], t["min"], t["sec"]) 133 | end 134 | 135 | -- _get_server_name: returns current nginx server name if ngx_lua is used. 136 | -- If ngx_lua is not used, returns "undefined" 137 | local function _get_server_name() 138 | return ngx and ngx.var.server_name or "undefined" 139 | end 140 | 141 | local function backtrace(level) 142 | local frames = {} 143 | 144 | level = level + 1 145 | 146 | while true do 147 | local info = debug_getinfo(level, "Snl") 148 | if not info then 149 | break 150 | end 151 | 152 | table_insert(frames, 1, { 153 | filename = info.short_src, 154 | ["function"] = info.name, 155 | lineno = info.currentline, 156 | }) 157 | 158 | level = level + 1 159 | end 160 | return { frames = frames } 161 | end 162 | 163 | -- _parse_host_port: parse long host ("127.0.0.1:2222") 164 | -- to host ("127.0.0.1") and port (2222) 165 | local function _parse_host_port(protocol, host) 166 | local i = string_find(host, ":") 167 | if not i then 168 | -- TODO 169 | return host, nil 170 | end 171 | 172 | local port_str = string_sub(host, i + 1) 173 | local port = tonumber(port_str) 174 | if not port then 175 | return nil, nil, "illegal port: " .. port_str 176 | end 177 | 178 | return string_sub(host, 1, i - 1), port 179 | end 180 | _M._parse_host_port = _parse_host_port 181 | 182 | -- _parse_dsn: gets protocol, public_key, secret_key, host, port, path and 183 | -- project from DSN 184 | local function _parse_dsn(dsn, obj) 185 | if not obj then 186 | obj = {} 187 | end 188 | 189 | assert(type(obj) == "table") 190 | 191 | -- '{PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}/{PATH}{PROJECT_ID}' 192 | obj.protocol, obj.public_key, obj.secret_key, obj.long_host, 193 | obj.path, obj.project_id = 194 | string_match(dsn, "^([^:]+)://([^:]+):([^@]+)@([^/]+)(.*/)(.+)$") 195 | 196 | if obj.protocol and obj.public_key and obj.secret_key and obj.long_host 197 | and obj.project_id then 198 | 199 | local host, port, err = _parse_host_port(obj.protocol, obj.long_host) 200 | 201 | if not host then 202 | return nil, err 203 | end 204 | 205 | obj.host = host 206 | obj.port = port 207 | 208 | obj.request_uri = obj.path .. "api/" .. obj.project_id .. "/store/" 209 | obj.server = obj.protocol .. "://" .. obj.long_host .. obj.request_uri 210 | 211 | return obj 212 | end 213 | 214 | return nil, "failed to parse DSN string" 215 | end 216 | _M._parse_dsn = _parse_dsn 217 | 218 | --- Create a new Sentry client. Three parameters: 219 | -- @param self raven client 220 | -- @param dsn The DSN of the Sentry instance with this format: 221 | --
{PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}/{PATH}{PROJECT_ID}
222 | --
http://pub:secret@127.0.0.1:8080/sentry/proj-id
223 | -- @param conf client configuration. Conf should be a hash table. Possiable 224 | -- keys are: "tags", "logger". For example: 225 | --
{ tags = { foo = "bar", abc = "def" }, logger = "myLogger" }
226 | -- @return a new raven instance 227 | -- @usage 228 | -- local raven = require "raven" 229 | -- local rvn = raven:new(dsn, { tags = { foo = "bar", abc = "def" }, 230 | -- logger = "myLogger" }) 231 | function _M.new(self, dsn, conf) 232 | if not dsn then 233 | return nil, "empty dsn" 234 | end 235 | 236 | local obj = {} 237 | 238 | local ok, err = _parse_dsn(dsn, obj) 239 | if not ok then 240 | return nil, err 241 | end 242 | 243 | obj.client_id = "raven-lua/0.4" 244 | -- default level "error" 245 | obj.level = "error" 246 | 247 | if conf then 248 | if conf.tags then 249 | obj.tags = { conf.tags } 250 | end 251 | 252 | if conf.logger then 253 | obj.logger = conf.logger 254 | end 255 | end 256 | 257 | return setmetatable(obj, mt) 258 | end 259 | 260 | --- Send an exception to Sentry. 261 | -- see reference. 262 | -- 263 | -- @param self raven client 264 | -- @param exception a hash table describing an exception. For example: 265 | --
{{
266 | --     ["type"] = "SyntaxError",
267 | --     ["value"] = "Wattttt!",
268 | --     ["module"] = "__builtins__",
269 | --     stacktrace = {
270 | --         frames = {
271 | --             { filename = "/real/file/name", func = "myfunc", lineno" = 3 },
272 | --             { filename = "/real/file/name", func = "myfunc1", lineno" = 10 },
273 | --         }
274 | --     }
275 | -- }}
276 | -- 277 | -- @param conf capture configuration. Conf should be a hash table. 278 | -- Possible keys are: "tags", "trace_level". "tags" will be 279 | -- send to Sentry together with "tags" in client 280 | -- configuration. "trace_level" is used for geting stack 281 | -- backtracing. You shouldn't pass this argument unless you 282 | -- know what you are doing. 283 | -- @return On success, return event id. If not success, return nil and 284 | -- an error string. 285 | -- @usage 286 | -- local raven = require "raven" 287 | -- local rvn = raven:new(dsn, { tags = { foo = "bar", abc = "def" }, 288 | -- logger = "myLogger" }) 289 | -- local id, err = rvn:captureException(exception, 290 | -- { tags = { foo = "bar", abc = "def" }}) 291 | function _M.captureException(self, exception, conf) 292 | local trace_level 293 | if not conf then 294 | conf = { trace_level = 2 } 295 | elseif not conf.trace_level then 296 | conf.trace_level = 2 297 | else 298 | conf.trace_level = conf.trace_level + 1 299 | end 300 | 301 | trace_level = conf.trace_level 302 | 303 | clear_tab(_json) 304 | exception[1].stacktrace = backtrace(trace_level) 305 | _json.exception = exception 306 | _json.message = exception[1].value 307 | 308 | _json.culprit = self.get_culprit(conf.trace_level) 309 | 310 | 311 | -- because whether tail call will or will not appear in the stack back trace 312 | -- is different between PUC-lua or LuaJIT, so just avoid tail call 313 | local id, err = self:send_report(_json, conf) 314 | return id, err 315 | end 316 | 317 | --- Send a message to Sentry. 318 | -- 319 | -- @param self raven client 320 | -- @param message arbitrary message (most likely an error string) 321 | -- @param conf capture configuration. Conf should be a hash table. 322 | -- Possiable keys are: "tags", "trace_level". "tags" will be 323 | -- send to Sentry together with "tags" in client 324 | -- configuration. "trace_level" is used for geting stack 325 | -- backtracing. You shouldn't pass this argument unless you 326 | -- know what you are doing. 327 | -- @return On success, return event id. If not success, return nil and 328 | -- error string. 329 | -- @usage 330 | -- local raven = require "raven" 331 | -- local rvn = raven:new(dsn, { tags = { foo = "bar", abc = "def" }, 332 | -- logger = "myLogger" }) 333 | -- local id, err = rvn:captureMessage("Sample message", 334 | -- { tags = { foo = "bar", abc = "def" }}) 335 | function _M.captureMessage(self, message, conf) 336 | if not conf then 337 | conf = { trace_level = 2 } 338 | elseif not conf.trace_level then 339 | conf.trace_level = 2 340 | else 341 | conf.trace_level = conf.trace_level + 1 342 | end 343 | 344 | clear_tab(_json) 345 | _json.message = message 346 | 347 | _json.culprit = self.get_culprit(conf.trace_level) 348 | 349 | local id, err = self:send_report(_json, conf) 350 | return id, err 351 | end 352 | 353 | -- send_report: send report for the captured error. 354 | -- 355 | -- Parameters: 356 | -- json: json table to be sent. Don't need to fill event_id, culprit, 357 | -- timestamp and level, send_report will fill these fields for you. 358 | function _M.send_report(self, json, conf) 359 | local event_id = uuid4() 360 | 361 | -- TODO: Why is this line commented out? 362 | --json.project = self.project_id, 363 | 364 | if not json then 365 | json = self.json 366 | if not json then 367 | return 368 | end 369 | end 370 | 371 | json.event_id = event_id 372 | json.timestamp = iso8601() 373 | json.level = self.level 374 | json.tags = self.tags 375 | json.platform = "lua" 376 | json.logger = "root" 377 | 378 | if conf then 379 | if conf.tags then 380 | if not json.tags then 381 | json.tags = { conf.tags } 382 | else 383 | json.tags[#json.tags + 1] = conf.tags 384 | end 385 | end 386 | 387 | if conf.level then 388 | json.level = conf.level 389 | end 390 | end 391 | 392 | json.server_name = _get_server_name() 393 | 394 | local json_str = json_encode(json) 395 | local ok, err 396 | if self.protocol == "https" then 397 | ok, err = self:http_send(json_str, true) 398 | elseif self.protocol == "http" then 399 | ok, err = self:http_send(json_str, false) 400 | else 401 | error("protocol not implemented yet: " .. self.protocol) 402 | end 403 | 404 | if not ok then 405 | errlog("Failed to send to Sentry: ", err, " ", json_str) 406 | return nil, err 407 | end 408 | return json.event_id 409 | end 410 | 411 | -- get culprit using given level 412 | function _M.get_culprit(level) 413 | local culprit 414 | 415 | level = level + 1 416 | local info = debug_getinfo(level, "Snl") 417 | if info.name then 418 | culprit = info.name 419 | else 420 | culprit = info.short_src .. ":" .. info.linedefined 421 | end 422 | return culprit 423 | end 424 | 425 | -- catcher: used to catch an error from xpcall. 426 | function _M.catcher(self, err) 427 | if debug then 428 | log("catch: ", err) 429 | end 430 | 431 | clear_tab(_exception[1]) 432 | _exception[1].value = err 433 | _exception[1].stacktrace = backtrace(catcher_trace_level) 434 | 435 | clear_tab(_json) 436 | _json.exception = _exception 437 | _json.message = _exception[1].value 438 | 439 | _json.culprit = self.get_culprit(catcher_trace_level) 440 | 441 | return _json 442 | end 443 | 444 | --- Call function f with parameters ... wrapped in a xpcall and 445 | -- send any exception to Sentry. Returns a boolean indicating whether 446 | -- the function execution worked and an error if not 447 | -- @param self raven client 448 | -- @param f function to be called 449 | -- @param ... function "f" 's arguments 450 | -- @return the same with xpcall 451 | -- @usage 452 | -- function func(a, b, c) 453 | -- return a * b + c 454 | -- end 455 | -- return rvn:call(func, a, b, c) 456 | function _M.call(self, f, ...) 457 | -- When used with ngx_lua, connecting a tcp socket in xpcall error handler 458 | -- will cause a "yield across C-call boundary" error. To avoid this, we 459 | -- move all the network operations outside of the xpcall error handler. 460 | local json_exception 461 | local res = { xpcall(f, 462 | function (err) 463 | local ok 464 | ok, json_exception = pcall(self.catcher, self, err) 465 | if not ok then 466 | -- when failed, json_exception is error message 467 | errlog(json_exception) 468 | end 469 | return err 470 | end, 471 | ...) } 472 | if json_exception then 473 | self:send_report(json_exception) 474 | end 475 | 476 | return unpack(res) 477 | end 478 | 479 | function _M.gen_capture_err(self) 480 | return function (err) 481 | local ok, json_exception = pcall(self.catcher, self, err) 482 | if not ok then 483 | -- when failed, json_exception is error message 484 | errlog(json_exception) 485 | self.json = nil 486 | else 487 | self.json = json_exception 488 | end 489 | return err 490 | end 491 | end 492 | 493 | -- HTTP request template 494 | local xsentryauth_http = "POST %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: %d\r\nUser-Agent: %s\r\nX-Sentry-Auth: Sentry sentry_version=5, sentry_client=%s, sentry_timestamp=%s, sentry_key=%s, sentry_secret=%s\r\n\r\n%s" 495 | 496 | -- http_send_core: do the actual network send. Expects an already 497 | -- connected socket. 498 | function _M.http_send_core(self, json_str) 499 | local req = string_format(xsentryauth_http, 500 | self.request_uri, 501 | self.long_host, 502 | #json_str, 503 | self.client_id, 504 | self.client_id, 505 | iso8601(), 506 | self.public_key, 507 | self.secret_key, 508 | json_str) 509 | local bytes, err = self.sock:send(req) 510 | if not bytes then 511 | return nil, err 512 | end 513 | 514 | local res, err = self.sock:receive("*a") 515 | if not res then 516 | return nil, err 517 | end 518 | 519 | local s1, s2, status = string_find(res, "HTTP/%d%.%d (%d%d%d) %w+") 520 | if status ~= "200" then 521 | return nil, "Server response status not 200:" .. (status or "nil") 522 | end 523 | 524 | local s1, s2 = string_find(res, "\r\n\r\n") 525 | if not s1 and s2 then 526 | return "" 527 | end 528 | return string_sub(res, s2 + 1) 529 | end 530 | 531 | function _M.set_luasec_params(self, params) 532 | self._luasec_params = params 533 | end 534 | 535 | -- lua_wrap_tls: Enables TLS for luasocket. Requires luasec. 536 | function _M.lua_wrap_tls(self, sock) 537 | if not ssl then 538 | error("no ssl library found, please install luasec") 539 | end 540 | 541 | local ok, err 542 | 543 | if self._luasec_params then 544 | sock, err = ssl.wrap(sock, self._luasec_params) 545 | else 546 | sock, err = ssl.wrap(sock, { 547 | mode = "client", 548 | protocol = "tlsv1", 549 | verify = "peer", 550 | options = "all", 551 | }) 552 | end 553 | if not sock then 554 | return nil, err 555 | end 556 | 557 | ok, err = sock:dohandshake() 558 | if not ok then 559 | return nil, err 560 | end 561 | 562 | return sock 563 | end 564 | 565 | -- ngx_wrap_tls: Enables TLS for ngx.socket 566 | function _M.ngx_wrap_tls(self, sock) 567 | local peer_verify = true 568 | if self._luasec_params then 569 | if self._luasec_params.verify == "none" then 570 | peer_verify = false 571 | end 572 | end 573 | 574 | local session, err = sock:sslhandshake(false, self.host, peer_verify) 575 | if not session then 576 | return nil, err 577 | end 578 | return sock 579 | end 580 | 581 | -- wrap_tls: Wraps a connected socket with TLS 582 | function _M.wrap_tls(self, sock) 583 | if ngx then 584 | return self:ngx_wrap_tls(sock) 585 | end 586 | return self:lua_wrap_tls(sock) 587 | end 588 | 589 | -- http_send: actually sends the structured data to the Sentry server using 590 | -- HTTP or HTTPS 591 | function _M.http_send(self, json_str, secure) 592 | local ok, err 593 | local sock 594 | local port = self.port 595 | 596 | sock, err = socket.tcp() 597 | if not sock then 598 | return nil, err 599 | end 600 | 601 | -- Rely on default port values for http and https 602 | if not port then 603 | port = secure and 443 or 80 604 | end 605 | 606 | ok, err = sock:connect(self.host, port) 607 | if not ok then 608 | return nil, err 609 | end 610 | 611 | if secure then 612 | -- Sprinkle on some TLS juice 613 | local tlssock, err = self:wrap_tls(sock) 614 | if not tlssock then 615 | -- Need to close the tcp connection yet before bailing 616 | sock:close() 617 | return nil, err 618 | end 619 | sock = tlssock 620 | end 621 | 622 | self.sock = sock 623 | 624 | ok, err = self:http_send_core(json_str) 625 | 626 | sock:close() 627 | return ok, err 628 | end 629 | 630 | -- test client’s configuration from CLI 631 | local function raven_test(dsn) 632 | local rvn, err = _M.new(_M, dsn, { tags = { source = "CLI test DSN" }}) 633 | 634 | if not rvn then 635 | print(err) 636 | end 637 | 638 | print(string_format("Using DSN configuration:\n %s\n", dsn)) 639 | print(string_format([[Client configuration: 640 | Servers : ['%s'] 641 | project : %s 642 | public_key : %s 643 | secret_key : %s 644 | ]], rvn.server, rvn.project_id, rvn.public_key, rvn.secret_key)) 645 | print("Send a message...") 646 | local msg = "Hello from raven-lua!" 647 | local id, err = rvn:captureMessage(msg) 648 | 649 | if id then 650 | print("success!") 651 | print("Event id was '" .. id .. "'") 652 | else 653 | print("failed to send message '" .. msg .. "'\n" .. tostring(err)) 654 | end 655 | 656 | print("Send an exception...") 657 | local exception = {{ 658 | ["type"] = "SyntaxError", 659 | ["value"] = "Wattttt!", 660 | ["module"] = "__builtins__" 661 | }} 662 | local id, err = rvn:captureException(exception) 663 | 664 | if id then 665 | print("success!") 666 | print("Event id was '" .. id .. "'") 667 | else 668 | print("failed to send message '" .. msg .. "'\n" .. err) 669 | end 670 | print("All done.") 671 | end 672 | 673 | if arg and arg[1] and arg[1] == "test" then 674 | local dsn = arg[2] 675 | raven_test(dsn) 676 | end 677 | 678 | return _M 679 | -------------------------------------------------------------------------------- /external/resty/cookie.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (C) 2013 Jiale Zhi (calio), Cloudflare Inc. 2 | -- See RFC6265 http://tools.ietf.org/search/rfc6265 3 | -- require "luacov" 4 | 5 | local type = type 6 | local byte = string.byte 7 | local sub = string.sub 8 | local format = string.format 9 | local log = ngx.log 10 | local ERR = ngx.ERR 11 | local ngx_header = ngx.header 12 | 13 | local EQUAL = byte("=") 14 | local SEMICOLON = byte(";") 15 | local SPACE = byte(" ") 16 | local HTAB = byte("\t") 17 | 18 | 19 | local ok, new_tab = pcall(require, "table.new") 20 | if not ok then 21 | new_tab = function (narr, nrec) return {} end 22 | end 23 | 24 | local ok, clear_tab = pcall(require, "table.clear") 25 | if not ok then 26 | clear_tab = function(tab) for k, _ in pairs(tab) do tab[k] = nil end end 27 | end 28 | 29 | local _M = new_tab(0, 2) 30 | 31 | _M._VERSION = '0.01' 32 | 33 | 34 | local mt = { __index = _M } 35 | 36 | 37 | local function get_cookie_table(text_cookie) 38 | if type(text_cookie) ~= "string" then 39 | log(ERR, format("expect text_cookie to be \"string\" but found %s", 40 | type(text_cookie))) 41 | return {} 42 | end 43 | 44 | local EXPECT_KEY = 1 45 | local EXPECT_VALUE = 2 46 | local EXPECT_SP = 3 47 | 48 | local n = 0 49 | local len = #text_cookie 50 | 51 | for i=1, len do 52 | if byte(text_cookie, i) == SEMICOLON then 53 | n = n + 1 54 | end 55 | end 56 | 57 | local cookie_table = new_tab(0, n + 1) 58 | 59 | local state = EXPECT_SP 60 | local i = 1 61 | local j = 1 62 | local key, value 63 | 64 | while j <= len do 65 | if state == EXPECT_KEY then 66 | if byte(text_cookie, j) == EQUAL then 67 | key = sub(text_cookie, i, j - 1) 68 | state = EXPECT_VALUE 69 | i = j + 1 70 | end 71 | elseif state == EXPECT_VALUE then 72 | if byte(text_cookie, j) == SEMICOLON 73 | or byte(text_cookie, j) == SPACE 74 | or byte(text_cookie, j) == HTAB 75 | then 76 | value = sub(text_cookie, i, j - 1) 77 | cookie_table[key] = value 78 | 79 | key, value = nil, nil 80 | state = EXPECT_SP 81 | i = j + 1 82 | end 83 | elseif state == EXPECT_SP then 84 | if byte(text_cookie, j) ~= SPACE 85 | and byte(text_cookie, j) ~= HTAB 86 | then 87 | state = EXPECT_KEY 88 | i = j 89 | j = j - 1 90 | end 91 | end 92 | j = j + 1 93 | end 94 | 95 | if key ~= nil and value == nil then 96 | cookie_table[key] = sub(text_cookie, i) 97 | end 98 | 99 | return cookie_table 100 | end 101 | 102 | function _M.new(self) 103 | local _cookie = ngx.var.http_cookie 104 | --if not _cookie then 105 | --return nil, "no cookie found in current request" 106 | --end 107 | return setmetatable({ _cookie = _cookie, set_cookie_table = new_tab(4, 0) }, 108 | mt) 109 | end 110 | 111 | function _M.get(self, key) 112 | if not self._cookie then 113 | return nil, "no cookie found in the current request" 114 | end 115 | if self.cookie_table == nil then 116 | self.cookie_table = get_cookie_table(self._cookie) 117 | end 118 | 119 | return self.cookie_table[key] 120 | end 121 | 122 | function _M.get_all(self) 123 | local err 124 | 125 | if not self._cookie then 126 | return nil, "no cookie found in the current request" 127 | end 128 | 129 | if self.cookie_table == nil then 130 | self.cookie_table = get_cookie_table(self._cookie) 131 | end 132 | 133 | return self.cookie_table 134 | end 135 | 136 | local function bake(cookie) 137 | if not cookie.key or not cookie.value then 138 | return nil, 'missing cookie field "key" or "value"' 139 | end 140 | 141 | if cookie["max-age"] then 142 | cookie.max_age = cookie["max-age"] 143 | end 144 | local str = cookie.key .. "=" .. cookie.value 145 | .. (cookie.expires and "; Expires=" .. cookie.expires or "") 146 | .. (cookie.max_age and "; Max-Age=" .. cookie.max_age or "") 147 | .. (cookie.domain and "; Domain=" .. cookie.domain or "") 148 | .. (cookie.path and "; Path=" .. cookie.path or "") 149 | .. (cookie.secure and "; Secure" or "") 150 | .. (cookie.httponly and "; HttpOnly" or "") 151 | .. (cookie.extension and "; " .. cookie.extension or "") 152 | return str 153 | end 154 | 155 | function _M.set(self, cookie) 156 | local cookie_str, err = bake(cookie) 157 | if not cookie_str then 158 | return nil, err 159 | end 160 | 161 | local set_cookie = ngx_header['Set-Cookie'] 162 | local set_cookie_type = type(set_cookie) 163 | local t = self.set_cookie_table 164 | clear_tab(t) 165 | 166 | if set_cookie_type == "string" then 167 | -- only one cookie has been setted 168 | if set_cookie ~= cookie_str then 169 | t[1] = set_cookie 170 | t[2] = cookie_str 171 | ngx_header['Set-Cookie'] = t 172 | end 173 | elseif set_cookie_type == "table" then 174 | -- more than one cookies has been setted 175 | local size = #set_cookie 176 | 177 | -- we can not set cookie like ngx.header['Set-Cookie'][3] = val 178 | -- so create a new table, copy all the values, and then set it back 179 | for i=1, size do 180 | t[i] = ngx_header['Set-Cookie'][i] 181 | if t[i] == cookie_str then 182 | -- new cookie is duplicated 183 | return true 184 | end 185 | end 186 | t[size + 1] = cookie_str 187 | ngx_header['Set-Cookie'] = t 188 | else 189 | -- no cookie has been setted 190 | ngx_header['Set-Cookie'] = cookie_str 191 | end 192 | return true 193 | end 194 | 195 | return _M 196 | -------------------------------------------------------------------------------- /nginx/sso-init.conf: -------------------------------------------------------------------------------- 1 | ## PLACE IN NGINX CONF.D ## 2 | 3 | # Only disable for debugging. 4 | lua_code_cache on; 5 | 6 | limit_req_zone $binary_remote_addr zone=lsso:10m rate=5r/s; 7 | 8 | init_by_lua_file /path/to/lsso/src/init.lua; 9 | -------------------------------------------------------------------------------- /nginx/sso-site.conf: -------------------------------------------------------------------------------- 1 | ## PLACE IN NGINX SITES.D ## 2 | 3 | server { 4 | listen 80; 5 | listen [::]:80; 6 | 7 | server_name sso.example.org 8 | root /var/www/sso.example.org; 9 | 10 | return 301 https://$server_name$request_uri; 11 | } 12 | 13 | server { 14 | listen 443 ssl; 15 | listen [::]:443 ssl; 16 | 17 | server_name sso.example.org; 18 | root /var/www/sso.example.org; 19 | 20 | ssl_certificate /etc/ssl/sso.example.crt; 21 | ssl_certificate_key /etc/ssl/example.key; 22 | 23 | location = / { 24 | rewrite ^/$ /auth/ permanent; 25 | } 26 | 27 | location /auth/ { 28 | index index.html; 29 | try_files $uri $uri/ =404; 30 | } 31 | 32 | location /auth/verify { 33 | default_type 'text/plain'; 34 | content_by_lua_file /root/lsso/src/access.lua; 35 | } 36 | 37 | location /api { 38 | limit_req zone=lsso burst=5; 39 | 40 | default_type 'text/plain'; 41 | content_by_lua_file /root/lsso/src/api.lua; 42 | } 43 | 44 | location /api/log { 45 | limit_req zone=lsso burst=5; 46 | 47 | default_type 'text/plain'; 48 | # NOTE: This access directive works, but you must have a session cookie to access the logs. 49 | # This limitation will be removed with the introduction of basic auth. 50 | access_by_lua_file /root/lsso/src/access.lua; 51 | content_by_lua_file /root/lsso/src/api.lua; 52 | } 53 | 54 | location /resources/ { 55 | try_files $uri $uri/ =404; 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /nginx/upstream.conf: -------------------------------------------------------------------------------- 1 | ## 2 | # UPSTREAMS 3 | # CONNECTION SETTINGS 4 | ## PLACE IN NGINX CONF.D ## 5 | ## 6 | 7 | upstream oauth-server { 8 | server 127.0.0.1:8124; 9 | } 10 | -------------------------------------------------------------------------------- /pages/README.md: -------------------------------------------------------------------------------- 1 | lsso/pages 2 | ========== 3 | 4 | Example pages for doing different things with LSSO. 5 | 6 | auth/ contains an example authentication page to post to the auth endpoint for logging in. 7 | 8 | To set up these pages, enter the resources/ directory and do `bower install`. Everything should be taken care of for you. 9 | -------------------------------------------------------------------------------- /pages/auth/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | example sso 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 151 | 152 | 153 |
154 |
155 | 160 |
161 | 162 |
163 |
164 | 165 |
166 | 200 |
201 |
202 | 203 | 204 | 205 | 206 | -------------------------------------------------------------------------------- /pages/resources/.gitignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | -------------------------------------------------------------------------------- /pages/resources/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lsso-pages-resources", 3 | "version": "0.1.0", 4 | "authors": [ 5 | "Sean Johnson " 6 | ], 7 | "description": "Resources for LSSO pages.", 8 | "moduleType": [ 9 | "globals" 10 | ], 11 | "license": "MIT", 12 | "homepage": "https://github.com/maiome-development/lsso", 13 | "ignore": [ 14 | "**/.*", 15 | "node_modules", 16 | "bower_components", 17 | "test", 18 | "tests" 19 | ], 20 | "dependencies": { 21 | "bootstrap": "~3.3.5", 22 | "flat-ui": "~2.2.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pages/resources/summit-min.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Roboto+Condensed|Roboto'); 2 | 3 | html { 4 | min-height: 100%; 5 | padding-top: 20px; 6 | background-color: #f2f1ef; 7 | font-weight: 400; 8 | font-size: 1em; 9 | font-family: 'Roboto', sans-serif; 10 | line-height: 2em; 11 | padding-bottom: 1em; 12 | } 13 | 14 | body { 15 | background-color: #f2f1ef; 16 | margin-top: 15px; 17 | max-height: 98%; 18 | width: 98%; 19 | margin: 0 auto; 20 | } 21 | 22 | @font-face { 23 | font-family: 'Lobster'; 24 | font-style: normal; 25 | font-weight: 1000; 26 | src: local('Lobster'), url('https://themes.googleusercontent.com/static/fonts/lobster/v6/MWVf-Rwh4GLQVBEwbyI61Q.woff') format('woff'); 27 | } 28 | 29 | .logo-wrapper { 30 | color: #95a5a6 !important; 31 | font-family: 'Lobster'; 32 | font-size: 20px; 33 | width: 100%; 34 | text-decoration: none; 35 | vertical-align: middle; 36 | text-align: center; 37 | } 38 | 39 | .form-box { 40 | color: #34495e !important; 41 | padding: 38px !important; 42 | /* background: #95a5a6 !important; */ 43 | background-color: #d2d7d3 !important; 44 | border-radius: 6px !important; 45 | } 46 | 47 | .navbar-inverse { 48 | /* background-color: #6c7a89 !important; */ 49 | background-color: #d2d7d3 !important; 50 | } 51 | 52 | .navbar-nav > li > a { 53 | color: #95a5a6 !important; 54 | } 55 | 56 | span.account-uid { 57 | color: #abc1da !important; 58 | padding: 8px; 59 | } 60 | -------------------------------------------------------------------------------- /src/access.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- access.lua 3 | -- 4 | -- Script used by the nginx access_by_lua_file directive to determine 5 | -- if a user can access a protected resource. 6 | -- 7 | 8 | -- External library imports 9 | local cjson = require "cjson" 10 | local cookie = require "resty.cookie" 11 | 12 | -- Internal library imports 13 | local session = require "session" 14 | local util = require "util" 15 | 16 | -- Some shorthand. 17 | local lsso_login = config.lsso_scheme .. "://" .. config.lsso_domain .. config.lsso_login_redirect 18 | local lsso_capture = config.lsso_scheme .. "://" .. config.lsso_domain .. config.lsso_capture_location 19 | 20 | nginx_uri = ngx.var.uri 21 | nginx_server_name = ngx.var.server_name 22 | nginx_furl = ngx.var.scheme .. "://" .. nginx_server_name .. ngx.var.request_uri 23 | nginx_narg_url = ngx.var.scheme .. "://" .. nginx_server_name .. ngx.var.uri 24 | nginx_client_address = ngx.var.remote_addr 25 | nginx_client_useragent = ngx.req.get_headers()["User-Agent"] 26 | nginx_location_scope = ngx.var.lsso_location_scope 27 | 28 | -- The ngx.var API returns "" if the variable doesn't exist but is used elsewhere.. 29 | if nginx_location_scope == "" then 30 | nginx_location_scope = nil 31 | end 32 | 33 | local lsso_logging_context = { 34 | context = "logging", 35 | remote_addr = nginx_client_address, 36 | remote_ua = nginx_client_useragent, 37 | request_url = nginx_furl, 38 | request_scope = nginx_location_scope, 39 | req_id = util.generate_random_string(16), 40 | } 41 | 42 | local request_cookie = cookie:new() 43 | 44 | -- This block covers general authentication, situations such as: 45 | -- - POST to the capture endpoint 46 | -- - Simple session check-in 47 | -- - Preparing cross-domain-auth keys on redirect requests from main auth 48 | 49 | if nginx_narg_url == lsso_capture then 50 | local user_session = request_cookie:get(util.cookie_key("Session")) 51 | local user_redirect = request_cookie:get(util.cookie_key("Redirect")) 52 | 53 | if user_session == "nil" then 54 | user_session = nil 55 | end 56 | 57 | if user_redirect == "nil" then 58 | user_redirect = nil 59 | end 60 | 61 | if user_session then 62 | local okay, session_data = util.func_call(session.resolve_session, user_session) 63 | local okay, session_valid = util.func_call(session.check_session, user_session, true) 64 | 65 | if okay and session_valid then 66 | -- Anytime the session passes through a full check, we need to do a check-in. 67 | session.set_checkin_time(user_session, ngx.now()) 68 | 69 | -- Check for ?next= in URI, as that takes precedence over redirects. 70 | local uri_args = ngx.req.get_uri_args() 71 | if util.key_in_table(uri_args, "next") then 72 | local next_url = uri_args["next"] 73 | if not next_url then 74 | -- Redirect to lsso_capture to hit the session block 75 | -- Will redirect to cookie value or default_redirect 76 | ngx.redirect(lsso_capture) 77 | end 78 | 79 | -- Decode the URL and clear the redirect cookie 80 | next_url = ngx.decode_base64(next_url) 81 | util.set_cookie("Redirect") 82 | 83 | -- Generate a CDK and append to next_url 84 | local cross_domain_key = session.create_cdk_session(user_session) 85 | local cross_domain_arg = "?" .. config.lsso_cross_domain_qs .. "=" .. cross_domain_key 86 | 87 | -- Redirect to our next CDA location! 88 | if string.endswith(next_url, "/") then 89 | next_url = next_url .. cross_domain_arg 90 | else 91 | next_url = next_url .. "/" .. cross_domain_arg 92 | end 93 | ngx.redirect(next_url) 94 | end 95 | 96 | -- Check for regular redirect and send the user. 97 | if user_redirect then 98 | if not util.key_in_table(__scopes, user_redirect) then 99 | util.set_cookie("Redirect") 100 | ngx.redirect(user_redirect) 101 | else 102 | if not util.value_in_table(session_data.scope, __scopes[user_redirect]) then 103 | -- Have to invalidate the current session and create a new one. 104 | session.invalidate_session(user_session) 105 | -- Delete Session cookie. 106 | util.set_cookie("Session") 107 | -- Log and redirect to login page with error message. 108 | util.session_log("Attempting scope upgrade for session: " .. user_session, lsso_logging_context) 109 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_scope_upgrade) 110 | ngx.redirect(redir_uri) 111 | else 112 | util.set_cookie("Redirect") 113 | ngx.redirect(user_redirect) 114 | end 115 | end 116 | else 117 | ngx.redirect(config.lsso_default_redirect) 118 | end 119 | elseif not session_valid then 120 | util.set_cookie("Session") 121 | util.set_cookie("Redirect") 122 | if user_redirect then 123 | -- Session was invalidated and a redirect was attempted. 124 | -- Reset the cookies and redirect to login. 125 | util.session_log("Attempted access from bad session: " .. user_session, lsso_logging_context) 126 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_bad_session) 127 | ngx.redirect(redir_uri) 128 | end 129 | end 130 | elseif not user_session and ngx.req.get_method() == "GET" then 131 | -- This is from a hit from a CDA location, need to redirect to login properly. 132 | -- Check for ?next= in URI, as that takes precedence over redirects. 133 | local uri_args = ngx.req.get_uri_args() 134 | local login_uri = lsso_login 135 | 136 | if util.key_in_table(uri_args, "next") then 137 | local next_uri = uri_args["next"] 138 | ngx.req.set_uri_args({ 139 | ["next"] = next_uri, 140 | }) 141 | local redirect_arg = "?" .. ngx.var.args 142 | login_uri = lsso_login .. redirect_arg 143 | end 144 | 145 | ngx.redirect(login_uri) 146 | end 147 | 148 | -- Past the initial session routine, we need to enforce POST access only. 149 | if ngx.req.get_method() ~= "POST" then 150 | ngx.redirect(lsso_login) 151 | end 152 | 153 | -- Since we're here, this should be a POST request with a username and password. 154 | ngx.req.read_body() 155 | local credentials = ngx.req.get_post_args() 156 | 157 | if util.key_in_table(credentials, "access_token") then 158 | local access_token = credentials["access_token"] 159 | if access_token == "" or access_token == nil then 160 | goto skip_access_token 161 | end 162 | 163 | util.auth_log("Attempting to open session from access_token!", lsso_logging_context) 164 | 165 | local okay, access_info = util.func_call(session.resolve_access_token, access_token, false) 166 | if not access_info then 167 | util.auth_log("Failed access token lookup: " .. access_token, lsso_logging_context) 168 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_no_permission) 169 | ngx.redirect(redir_uri) 170 | end 171 | 172 | local okay, session_info = util.func_call(session.resolve_session, access_info.session, true) 173 | if not session_info then 174 | util.auth_log("Failed session lookup from access token: " .. access_token, lsso_logging_context) 175 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_no_permission) 176 | ngx.redirect(redir_uri) 177 | end 178 | 179 | local expire_at = ngx.cookie_time(access_info.created + access_info.ttl) 180 | util.set_cookie("Session", access_info.session, expire_at) 181 | 182 | util.auth_log("Opened session from access token: " .. access_token, lsso_logging_context) 183 | 184 | local user_redirect = request_cookie:get(util.cookie_key("Redirect")) 185 | if user_redirect then 186 | util.set_cookie("Redirect") 187 | ngx.redirect(user_redirect) 188 | else 189 | ngx.redirect(config.lsso_default_redirect) 190 | end 191 | ::skip_access_token:: 192 | end 193 | 194 | -- Make sure we have been provided credentials for login. 195 | if not util.key_in_table(credentials, "user") then 196 | util.auth_log("Attempted login without `user` field.", lsso_logging_context) 197 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_no_user_field) 198 | ngx.redirect(redir_uri) 199 | end 200 | 201 | if not util.key_in_table(credentials, "password") then 202 | util.auth_log("Attempted login without `password` field.", lsso_logging_context) 203 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_no_pw_field) 204 | ngx.redirect(redir_uri) 205 | end 206 | 207 | -- Create the auth table and convert it to JSON. 208 | local auth_table = {} 209 | util.merge_tables(config.oauth_auth_context, auth_table) 210 | 211 | username = ngx.escape_uri(credentials["user"]) 212 | password = ngx.escape_uri(credentials["password"]) 213 | 214 | -- Grab the 'next' field. 215 | local next_uri = credentials["next"] 216 | if next_uri then 217 | next_uri = ngx.decode_base64(next_uri) 218 | end 219 | 220 | -- Do scope processing magic. 221 | do 222 | local user_redirect = request_cookie:get(util.cookie_key("Redirect")) 223 | if not user_redirect and next_uri then 224 | user_redirect = next_uri 225 | end 226 | local location_scope = __scopes[user_redirect] or config.oauth_auth_scope 227 | 228 | -- Now that we have our requested location scope, ensure that we include the default scope 229 | -- if necessary, to reduce the number of scope upgrades. 230 | if location_scope ~= config.oauth_auth_scope then 231 | location_scope = string.format("%s %s", location_scope, config.oauth_auth_scope) 232 | end 233 | 234 | -- Merge the new scopes into the auth context table. 235 | util.merge_tables({ 236 | scope = location_scope, 237 | }, auth_table) 238 | end 239 | 240 | -- Construct the `Authorization` header 241 | auth_header = username .. ":" .. password 242 | auth_header = ngx.encode_base64(auth_header) 243 | auth_header = ("Basic %s"):format(auth_header) 244 | 245 | -- Encode the auth_table as qs args 246 | auth_table = ngx.encode_args(auth_table) 247 | 248 | -- Set request headers 249 | ngx.req.set_header("Authorization", auth_header) 250 | ngx.req.set_header("Content-Type", "application/x-www-form-urlencoded") 251 | 252 | -- Perform the token request. 253 | local okay, oauth_res = util.func_call(ngx.location.capture, config.oauth_auth_endpoint, { 254 | method = ngx.HTTP_POST, 255 | body = auth_table 256 | }) 257 | 258 | -- Check the HTTP response code and make sure the server didn't error out 259 | if util.http_status_class(oauth_res.status) == util.HTTP_SERVER_ERR then 260 | util.auth_log("Upstream communication error: " .. oauth_res.body, lsso_logging_context) 261 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_upstream_error) 262 | ngx.redirect(redir_uri) 263 | end 264 | 265 | -- Decode the OAuth response and make sure it did not return an error 266 | local auth_response = cjson.decode(oauth_res.body) 267 | 268 | -- Check for an OAuth error 269 | if util.key_in_table(auth_response, "error") then 270 | -- Auth request failed, process the information and redirect. 271 | util.auth_log("Received error from OAuth backend: " .. oauth_res.body) 272 | if auth_response["error"] == "invalid_scope" then 273 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_no_permission) 274 | ngx.redirect(redir_uri) 275 | else 276 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_bad_credentials) 277 | ngx.redirect(redir_uri) 278 | end 279 | else 280 | -- Success. Log it! 281 | util.auth_log("Auth success: " .. credentials["user"], lsso_logging_context) 282 | end 283 | 284 | -- Store token information in Redis. 285 | local session_key = util.generate_random_string(64) -- XXX - make length configurable? 286 | local session_salt = util.generate_random_string(8) -- Again, configurable length. 287 | rd_sess_key = util.redis_key("session:" .. session_key) 288 | current_time = ngx.now() 289 | 290 | util.session_log("Created new session: " .. session_key, lsso_logging_context) 291 | 292 | local initial_scopes = table.concat(auth_response.scopes, " ") 293 | 294 | -- Save the session in Redis 295 | rdc:pipeline(function(p) 296 | p:hset(rd_sess_key, "username", credentials["user"]) 297 | p:hset(rd_sess_key, "token", auth_response.access_token) 298 | p:hset(rd_sess_key, "scope", initial_scopes) 299 | p:hset(rd_sess_key, "created", current_time) 300 | p:hset(rd_sess_key, "remote_addr", nginx_client_address) 301 | p:hset(rd_sess_key, "salt", session_salt) 302 | p:hset(rd_sess_key, "origin", "active login") 303 | p:expire(rd_sess_key, config.cookie_lifetime) 304 | end) 305 | 306 | -- Check that the request host is a part of the cookie domain 307 | if not next_uri then 308 | local expire_at = ngx.cookie_time(ngx.time() + config.cookie_lifetime) 309 | util.set_cookie("Session", session_key, expire_at) 310 | 311 | -- Set the session checkin time. 312 | session.set_checkin_time(session_key, current_time) 313 | 314 | -- XXX - need to do processing here! 315 | local user_redirect = request_cookie:get(util.cookie_key("Redirect")) 316 | if user_redirect then 317 | util.set_cookie("Redirect") 318 | ngx.redirect(user_redirect) 319 | else 320 | ngx.redirect(config.lsso_default_redirect) 321 | end 322 | -- Otherwise, prepare for cross-domain authentication. 323 | else 324 | -- Make sure there is no redirect cookie... 325 | util.set_cookie("Redirect") 326 | 327 | -- Process the ?next= qs 328 | local base_scheme = nil 329 | local base_domain = ngx.re.match(next_uri, "(?https?)://(?[^/]+)/", "aosxi") 330 | 331 | if base_domain.base then 332 | base_scheme = base_domain.scheme 333 | base_domain = base_domain.base 334 | end 335 | 336 | -- Make sure the domain is in the list of allowed CDs 337 | if not session.get_cross_domain_base(base_domain) then 338 | util.session_log("CDA attempted on unlisted domain: " .. base_domain, lsso_logging_context) 339 | ngx.redirect(config.lsso_default_redirect) 340 | end 341 | 342 | -- Send the session key to the client 343 | local expire_at = ngx.cookie_time(ngx.time() + config.cookie_lifetime) 344 | util.set_cookie("Session", session_key, expire_at) 345 | 346 | -- Get a CDK and set up the next_uri 347 | local cross_domain_key = session.create_cdk_session(session_key) 348 | local cross_domain_arg = config.lsso_cross_domain_qs .. "=" .. cross_domain_key 349 | local next_uri_arg = "next=" .. ngx.encode_base64(next_uri) 350 | 351 | -- Redirect to the bare base domain with a CDK. 352 | local redirect_to = base_scheme .. "://" .. base_domain .. "/?" .. cross_domain_arg .. "&" .. next_uri_arg 353 | ngx.redirect(redirect_to) 354 | end 355 | elseif nginx_narg_url ~= lsso_capture then 356 | -- We're at anything other than the auth verification location. 357 | -- This means that we should check the session and redirect cookie. 358 | local user_session = request_cookie:get(util.cookie_key("Session")) 359 | local to_verify = false 360 | 361 | if user_session == "nil" then 362 | user_session = nil 363 | end 364 | 365 | -- Check for a set location scope. 366 | if not util.key_in_table(__scopes, nginx_furl) then 367 | if nginx_location_scope then 368 | util.merge_tables({ 369 | [nginx_furl] = nginx_location_scope, 370 | }, __scopes) 371 | else 372 | util.merge_tables({ 373 | [nginx_furl] = config.oauth_auth_scope, 374 | }, __scopes) 375 | end 376 | end 377 | 378 | local uri_args = ngx.req.get_uri_args() 379 | if util.key_in_table(uri_args, config.lsso_cross_domain_qs) then 380 | -- Get the CDK and next url! 381 | local cross_domain_key = uri_args[config.lsso_cross_domain_qs] 382 | local next_uri = uri_args["next"] 383 | local user_session = session.get_cdk_session(cross_domain_key) 384 | 385 | if not cross_domain_key or not user_session then 386 | util.session_log("No CDK or user session found!", lsso_logging_context) 387 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_no_access) 388 | ngx.redirect(redir_uri) 389 | end 390 | 391 | -- Need to do the cross domain redirection and session setting! 392 | -- Again, ensure there is no redirection cookie. 393 | do 394 | local expire_at = ngx.time() + config.cookie_lifetime 395 | local cdb = "." .. session.get_cross_domain_base(nginx_server_name) 396 | -- Set session in the client 397 | util.set_cookie("Redirect", nil, nil, cdb) 398 | util.set_cookie("Session", user_session, nil, cdb) 399 | end 400 | 401 | local current_time = ngx.now() 402 | 403 | -- Set the session checkin time. 404 | session.set_checkin_time(user_session, current_time) 405 | 406 | -- If there is no next_uri, strip the CDK arg off the current URL and redirect. 407 | if not next_uri and cross_domain_key then 408 | new_uri = string.gsub(nginx_furl, "?" .. config.lsso_cross_domain_qs .. "=" .. cross_domain_key, "") 409 | ngx.redirect(new_uri) 410 | end 411 | 412 | -- Decode the next_uri.. 413 | next_uri = ngx.decode_base64(next_uri) 414 | 415 | -- Finally redirect! 416 | ngx.redirect(next_uri) 417 | end 418 | 419 | if user_session then 420 | local okay, should_checkin = util.func_call(session.session_needs_checkin, user_session) 421 | if okay and should_checkin then 422 | util.session_log("Sending user to verify for checkin: " .. user_session, lsso_logging_context) 423 | to_verify = true 424 | else 425 | local okay, sess = util.func_call(session.resolve_session, user_session) 426 | if okay and sess then 427 | -- Verify that the user can access this location based on scope 428 | local loc_scope = __scopes[nginx_furl] or config.oauth_auth_scope 429 | if not util.value_in_table(sess.scope, loc_scope) then 430 | -- Redirect to login for re-auth to see if perms for this scope. 431 | util.session_log("User needs scope " .. loc_scope .. " but has " .. 432 | table.concat(sess.scope, " ") .. ", upgrading..", 433 | lsso_logging_context) 434 | 435 | local expire_at = ngx.cookie_time(ngx.time() + config.cookie_lifetime) 436 | util.set_cookie("Redirect", nginx_furl, expire_at) 437 | 438 | -- Invalidate the user session before redirect. 439 | session.invalidate_session(user_session) 440 | -- Delete session cookie. 441 | util.set_cookie("Session") 442 | -- Reset the redirect cookie so scopes are referenced correctly. 443 | util.set_cookie("Redirect", nginx_furl, expire_at) 444 | -- Redirect to login page. 445 | local redir_uri = session.encode_return_message(lsso_login, "error", config.msg_scope_upgrade) 446 | ngx.redirect(redir_uri) 447 | else 448 | return 449 | end 450 | end 451 | util.set_cookie("Redirect") 452 | return -- Allow access phase to continue 453 | end 454 | end 455 | 456 | -- Check for CDA 457 | if string.endswith(nginx_server_name, config.cookie_domain) then 458 | -- This is on the native domain SSO is served from, no need for CDA. 459 | local expire_at = ngx.cookie_time(ngx.time() + config.cookie_lifetime) 460 | util.set_cookie("Redirect", nginx_furl, expire_at) 461 | else 462 | -- This is NOT on the native domain. Have to start CDA. 463 | local uri_next = ngx.encode_base64(nginx_furl) 464 | ngx.req.set_uri_args({ 465 | ["next"] = uri_next, 466 | }) 467 | redirect_arg = "?" .. ngx.var.args 468 | 469 | if not user_session and not user_redirect then 470 | to_verify = true 471 | end 472 | 473 | -- Clear the redirect cookie since we won't be using it. 474 | if user_redirect then 475 | local cdb = "." .. session.get_cross_domain_base(nginx_server_name) 476 | util.set_cookie("Redirect", nil, nil, cdb) 477 | end 478 | end 479 | 480 | if redirect_arg then 481 | login_uri = lsso_login .. redirect_arg 482 | capture_uri = lsso_capture .. redirect_arg 483 | else 484 | login_uri = lsso_login 485 | capture_uri = lsso_capture 486 | end 487 | 488 | -- Redirect to SSO login page to auth. 489 | if (to_verify and redirect_arg) or to_verify then 490 | ngx.redirect(capture_uri) 491 | else 492 | ngx.redirect(login_uri) 493 | end 494 | end 495 | -------------------------------------------------------------------------------- /src/api.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- api.lua 3 | -- 4 | -- API for performing actions on LSSO. 5 | -- 6 | 7 | -- External library imports 8 | local cjson = require "cjson" 9 | local raven = require "raven" 10 | local redis = require "redis" 11 | 12 | -- Internal library imports 13 | local session = require "session" 14 | local util = require "util" 15 | 16 | local lsso_api = config.lsso_scheme .. "://" .. config.lsso_domain .. config.api_endpoint 17 | 18 | -- Processed nginx variables 19 | nginx_uri = ngx.var.uri 20 | nginx_server_name = ngx.var.server_name 21 | nginx_furl = ngx.var.scheme .. "://" .. nginx_server_name .. ngx.var.request_uri 22 | nginx_narg_url = ngx.var.scheme .. "://" .. nginx_server_name .. ngx.var.uri 23 | nginx_client_address = ngx.var.remote_addr 24 | nginx_client_useragent = ngx.req.get_headers()["User-Agent"] 25 | nginx_location_scope = ngx.var.lsso_location_scope 26 | nginx_uri_args = ngx.req.get_uri_args() 27 | 28 | -- The ngx.var API returns "" if the variable doesn't exist but is used elsewhere.. 29 | if nginx_location_scope == "" then 30 | nginx_location_scope = nil 31 | end 32 | 33 | local lsso_api_request = nginx_narg_url:chopstart(lsso_api) 34 | 35 | local lsso_logging_context = { 36 | context = "logging", 37 | remote_addr = nginx_client_address, 38 | remote_ua = nginx_client_useragent, 39 | request_url = nginx_furl, 40 | request_scope = nginx_location_scope, 41 | req_id = util.generate_random_string(16), 42 | origin = "api", 43 | } 44 | 45 | -- Non-consistent variables 46 | local redis_response = nil 47 | 48 | -- Actual API routes 49 | if lsso_api_request == "/_health" then 50 | ngx.say("okay") 51 | elseif lsso_api_request == "/token/request" then 52 | if ngx.req.get_method() ~= "POST" then 53 | ngx.say("Unable to GET /token/request") 54 | return 55 | end 56 | 57 | -- POST /token/request requires these parameters: 58 | -- - username: OAuth username [required] 59 | -- - password: OAuth password [required] 60 | -- - expire: number of seconds until token expiry [optional; defaults to cookie_lifetime] 61 | -- - scope: scope to create the access token under [optional; defaults to oauth_auth_scope] 62 | -- 63 | -- This API routine will essentially go through the entire session generation process, but 64 | -- it will just return an access token, which can be used to log in on the portal. 65 | ngx.req.read_body() 66 | local args = ngx.req.get_post_args() 67 | 68 | if not util.key_in_table(args, "username") then 69 | util.api_log("Attempted token request without `username` field.", lsso_logging_context) 70 | local err = { 71 | code = 400, 72 | message = "Missing `username` field", 73 | req_id = lsso_logging_context["req_id"], 74 | } 75 | err = cjson.encode(err) 76 | ngx.say(err) 77 | return 78 | end 79 | 80 | if not util.key_in_table(args, "password") then 81 | util.api_log("Attempted token request without `password` field.", lsso_logging_context) 82 | local err = { 83 | code = 400, 84 | message = "Missing `password` field", 85 | req_id = lsso_logging_context["req_id"], 86 | } 87 | err = cjson.encode(err) 88 | ngx.say(err) 89 | return 90 | end 91 | 92 | if not util.key_in_table(args, "expire") then 93 | util.api_log("Setting default expiry of " .. config.cookie_lifetime .. " for access token", 94 | lsso_logging_context) 95 | args["expire"] = config.cookie_lifetime 96 | else 97 | expire = tonumber(args["expire"]) or config.cookie_lifetime 98 | if expire > config.cookie_lifetime then 99 | util.api_log("Attempted to request access token with expiry over " .. config.cookie_lifetime, 100 | lsso_logging_context) 101 | args["expire"] = config.cookie_lifetime 102 | else 103 | args["expire"] = expire 104 | end 105 | end 106 | 107 | if not util.key_in_table(args, "scope") then 108 | util.api_log("Setting default scope of " .. config.oauth_auth_scope .. " for access token", 109 | lsso_logging_context) 110 | args["scope"] = config.oauth_auth_scope 111 | else 112 | -- XXX - add whitelisted/blacklisted scopes for token requests 113 | local scopes_req = args["scope"]:split(" ") 114 | local scopes = "" 115 | for _, v in pairs(scopes_req) do 116 | if not util.value_in_table(config.api_access_token_allowed_scopes, v) then 117 | util.api_log("Requested disallowed scope " .. v, lsso_logging_context) 118 | else 119 | scopes = scopes .. " " .. v 120 | end 121 | end 122 | args["scope"] = string.sub(scopes, 2) 123 | end 124 | 125 | -- Create auth args 126 | local auth_table = {} 127 | util.merge_tables(config.oauth_auth_context, auth_table) 128 | 129 | -- Construct the `Authorization` header 130 | auth_header = args["username"] .. ":" .. args["password"] 131 | auth_header = ngx.encode_base64(auth_header) 132 | auth_header = ("Basic %s"):format(auth_header) 133 | 134 | -- Add auth data to auth table, escape user-provided data 135 | auth_table["scope"] = ngx.escape_uri(args["scope"]) 136 | 137 | -- Merge details into the logging context 138 | util.merge_tables({ 139 | lsso_username = auth_table["username"], 140 | lsso_scope = auth_table["scope"], 141 | }, lsso_logging_context) 142 | 143 | auth_table = ngx.encode_args(auth_table) 144 | 145 | ngx.req.set_header("Authorization", auth_header) 146 | ngx.req.set_header("Content-Type", "application/x-www-form-urlencoded") 147 | local okay, oauth_res = util.func_call(ngx.location.capture, config.oauth_auth_endpoint, { 148 | method = ngx.HTTP_POST, 149 | body = auth_table, 150 | }) 151 | 152 | if util.http_status_class(oauth_res.status) == util.HTTP_SERVER_ERR then 153 | util.api_log("Upstream communication error: " .. oauth_res.body, lsso_logging_context) 154 | local err = { 155 | code = oauth_res.status, 156 | message = "Upstream communication error", 157 | req_id = lsso_logging_context["req_id"], 158 | } 159 | err = cjson.encode(err) 160 | ngx.say(err) 161 | return 162 | end 163 | 164 | -- Decode the OAuth response and make sure it did not return an error 165 | local auth_response = cjson.decode(oauth_res.body) 166 | 167 | -- Check for an OAuth error 168 | if util.key_in_table(auth_response, "error") then 169 | -- Auth request failed, process the information and redirect. 170 | util.auth_log("Received error from OAuth backend: " .. oauth_res.body) 171 | if auth_response["error"] == "invalid_scope" then 172 | local err = { 173 | code = oauth_res.status, 174 | message = auth_response["error"], 175 | req_id = lsso_logging_context["req_id"], 176 | } 177 | err = cjson.encode(err) 178 | ngx.say(err) 179 | return 180 | else 181 | local err = { 182 | code = oauth_res.status, 183 | message = auth_response["error"], 184 | req_id = lsso_logging_context["req_id"], 185 | } 186 | err = cjson.encode(err) 187 | ngx.say(err) 188 | return 189 | end 190 | else 191 | -- Success. Log it! 192 | util.auth_log("Auth success: " .. args["username"], lsso_logging_context) 193 | end 194 | 195 | -- Steps: 196 | -- 1) Create session first 197 | -- 2) Create access token which will resolve to a session. 198 | -- 3) Give the client the access token to enter on the portal 199 | -- 4) Keep session a secret UNTIL the access code is entered in the portal 200 | -- and assigned to the end user. At this point, the access code will be 201 | -- removed from the system. 202 | 203 | -- Store token information in Redis. 204 | local session_key = util.generate_random_string(64) -- XXX - make length configurable? 205 | local session_salt = util.generate_random_string(8) -- Again, configurable length. 206 | local rd_sess_key = util.redis_key("session:" .. session_key) 207 | local current_time = ngx.now() 208 | 209 | local initial_scopes = table.concat(auth_response.scopes, " ") 210 | 211 | -- Save the session in Redis 212 | rdc:pipeline(function(p) 213 | p:hset(rd_sess_key, "username", args["username"]) 214 | p:hset(rd_sess_key, "token", auth_response.access_token) 215 | p:hset(rd_sess_key, "scope", initial_scopes) 216 | p:hset(rd_sess_key, "created", current_time) 217 | p:hset(rd_sess_key, "remote_addr", nginx_client_address) 218 | p:hset(rd_sess_key, "salt", session_salt) 219 | p:hset(rd_sess_key, "origin", "api_access_token") 220 | p:expire(rd_sess_key, args["expire"]) 221 | end) 222 | 223 | util.session_log("Created new session: " .. session_key, lsso_logging_context) 224 | 225 | local access_token = util.generate_random_string(16) -- XXX - make length configurable? 226 | local rd_acc_key = util.redis_key("acctok:" .. access_token) 227 | 228 | rdc:pipeline(function(p) 229 | p:hset(rd_acc_key, "created", current_time) 230 | p:hset(rd_acc_key, "session", session_key) 231 | p:hset(rd_acc_key, "ttl", args["expire"]) 232 | p:expire(rd_acc_key, args["expire"]) 233 | end) 234 | 235 | util.api_log("Created new access token: " .. access_token, lsso_logging_context) 236 | 237 | local access_data = { 238 | code = 200, 239 | message = "Access token created", 240 | token = access_token, 241 | expires = current_time + args["expire"], 242 | username = args["username"], 243 | } 244 | access_data = cjson.encode(access_data) 245 | 246 | ngx.say(access_data) 247 | elseif lsso_api_request:startswith("/log/") then 248 | local bucket = lsso_api_request:chopstart("/log/") 249 | if util.value_in_table(util.LOG_BUCKETS, bucket) == nil then 250 | util.api_log("Requested bad bucket: " .. bucket, lsso_logging_context) 251 | local response = { 252 | code = 404, 253 | message = "Requested log bucket does not exist.", 254 | req_id = lsso_logging_context["req_id"], 255 | } 256 | response = cjson.encode(response) 257 | ngx.say(response) 258 | return 259 | end 260 | 261 | local page = nil 262 | local limit = nil 263 | 264 | -- Try and find ?page in the qs 265 | if util.key_in_table(nginx_uri_args, "page") then 266 | page = tonumber(nginx_uri_args["page"]) or nil 267 | end 268 | 269 | -- Try and find ?limit in the qs 270 | if util.key_in_table(nginx_uri_args, "limit") then 271 | limit = tonumber(nginx_uri_args["limit"]) or nil 272 | end 273 | 274 | util.api_log(string.format( 275 | "Requested bucket: %s [page=%d limit=%d]", 276 | bucket, 277 | page, 278 | limit 279 | )) 280 | 281 | local log_data = util.log_fetch(bucket, page, limit) 282 | local response = { 283 | code = 200, 284 | message = "okay", 285 | pagination = { 286 | ["page"] = page, 287 | ["limit"] = limit, 288 | }, 289 | ["response"] = log_data, 290 | } 291 | response = cjson.encode(response) 292 | ngx.say(response) 293 | end 294 | -------------------------------------------------------------------------------- /src/config.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- config.lua 3 | -- 4 | -- Configuration values for lsso. 5 | -- 6 | 7 | config = { 8 | -- Settings for Redis 9 | redis_address = "127.0.0.1", 10 | redis_port = 6379, 11 | redis_secret = nil, 12 | redis_db = 1, 13 | redis_key_prefix = "lsso:", 14 | 15 | -- lsso general settings 16 | 17 | -- Auth cookie settings 18 | cookie_prefix = "LSSO_", 19 | cookie_domain = "example.org", 20 | cookie_cross_domains = { 21 | "example.com", 22 | "example-infra.net" 23 | }, -- Table of domains that can do cross-domain authentication. 24 | cookie_lifetime = 21600, -- Lines up with oauth token expiry (value in seconds) 25 | 26 | -- Session settings 27 | -- Recommended: at least half your cookie lifetime, or half your key lifetime 28 | session_checkin = 10800, -- Time before validate_token is called to ensure the OAuth token is still active. 29 | session_logging = true, -- Log session messages to Redis (lsso:log:session) 30 | session_address_validation = true, -- Ensure a session hasn't switched IPs since last checkin. 31 | 32 | -- API settings 33 | api_logging = true, -- Log API messages to Redis (lsso:log:api) 34 | -- API will be exposed on lsso_scheme://lsso_domain .. api_endpoint 35 | api_endpoint = "/api", -- API endpoint...NO TRAILING SLASH! 36 | api_access_token_allowed_scopes = { 37 | "sso", 38 | "example", 39 | "other", 40 | }, 41 | 42 | -- Auth settings 43 | auth_logging = true, -- Log auth messages to Redis (lsso:log:auth) 44 | 45 | -- OAuth request settings 46 | -- 47 | -- These settings are for Osiris (https://github.com/sneridagh/osiris) 48 | -- Adjust as needed. Need to be on lsso_domain. 49 | oauth_auth_endpoint = "/token", -- Endpoint used for retrieving tokens 50 | oauth_token_endpoint = "/checktoken", -- Endpoint used for checking tokens 51 | oauth_auth_scope = "sso", -- Default scope to request for SSO access 52 | oauth_auth_context = { -- Additional context to send in the request to OAuth 53 | grant_type = "client_credentials", 54 | }, -- Additional static parameters that will be passed to the auth endpoint 55 | 56 | -- Location settings 57 | lsso_domain = "sso.example.org", -- Auth domain; No trailing slash! 58 | lsso_scheme = "https", 59 | lsso_login_redirect = "/auth", -- Endpoint to redirect to for auth. 60 | lsso_capture_location = "/auth/verify", -- Endpoint to capture for auth 61 | lsso_default_redirect = "https://example.org", -- Endpoint to redirect to when no ?next 62 | lsso_cross_domain_qs = "lsso_session", 63 | 64 | -- LuaSec SSL Settings; Used for raven 65 | luasec_params = { 66 | mode = "client", 67 | protocol = "tlsv1", 68 | options = "all", 69 | cafile = "/etc/ssl/cert.pem" 70 | }, 71 | 72 | -- Auth messages 73 | msg_bad_credentials = "Invalid username or password.", 74 | msg_bad_session = "Session is invalid. Please log in again.", 75 | msg_no_user_field = "Missing parameter: user", 76 | msg_no_pw_field = "Missing parameter: password", 77 | msg_no_access = "Please log in to access this resource.", 78 | msg_no_permission = "You do not have permission to access this resource.", 79 | msg_scope_upgrade = "Please log in again to upgrade your access.", 80 | msg_error = "Something happened while processing request data. Clear your cookies and try again.", 81 | msg_upstream_error = "Could not communicate with upstream...Try again later.", 82 | 83 | -- Debugging settings 84 | -- Debugging wraps calls and sends any exceptions to Sentry through Raven. 85 | debug_enabled = false, 86 | debug_dsn = "https://public:private@sentry.example.org/project_id" 87 | } 88 | -------------------------------------------------------------------------------- /src/init.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- init.lua 3 | -- 4 | -- Initializes lsso. 5 | -- Here, we initialize the Redis connection and do some other setup. 6 | -- 7 | 8 | -- Include libs in the base directory in package path. 9 | script_path = string.sub(debug.getinfo(1).source, 2, -9) 10 | package.path = package.path .. ";" .. script_path .. "?.lua" 11 | 12 | -- Change this to the path of your config.lua 13 | config_path = "/usr/local/etc/lsso.config.lua" 14 | 15 | -- Load libraries we need. 16 | local cjson = require "cjson" 17 | local raven = require "raven" 18 | local redis = require "redis" 19 | 20 | -- Load our local libraries 21 | local util = require "util" 22 | 23 | -- Load the config file. 24 | dofile(config_path) 25 | 26 | -- Globals for other parts of the application 27 | rdc = nil -- Redis client 28 | rvn = nil -- Raven client 29 | 30 | -- Scope mapping global table. 31 | __scopes = {} 32 | 33 | -- Initialize Redis 34 | rdc = redis.connect(config.redis_address, config.redis_port) 35 | local redis_response = nil 36 | 37 | if config.redis_secret then 38 | redis_response = rdc:auth(config.redis_secret) 39 | if redis_response then 40 | ngx.log(ngx.NOTICE, "Redis: authenticated with server") 41 | else 42 | ngx.log(ngx.NOTICE, "Redis: authentication failed") 43 | end 44 | end 45 | 46 | if config.redis_db then 47 | redis_response = rdc:select(config.redis_db) 48 | if redis_response then 49 | ngx.log(ngx.NOTICE, "Redis: switched to database " .. config.redis_db) 50 | end 51 | end 52 | 53 | -- Initialize Raven, if needed. 54 | if config.debug_enabled then 55 | rvn, err = raven:new(config.debug_dsn) 56 | if not rvn and err then 57 | ngx.log(ngx.NOTICE, "Raven: could not parse DSN: " .. err) 58 | config.debug_enabled = false 59 | end 60 | if config.luasec_params then 61 | rvn:set_luasec_params(config.luasec_params) 62 | end 63 | ngx.log(ngx.NOTICE, "Raven: initialized connection to " .. config.debug_dsn) 64 | end 65 | -------------------------------------------------------------------------------- /src/session.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- session.lua - functions for validating sessions 3 | -- 4 | 5 | module('session', package.seeall) 6 | 7 | -- Internal library imports 8 | local util = require "util" 9 | 10 | -- Some shorthand. 11 | local redis_response = nil 12 | 13 | -- Functions for session validation. 14 | function resolve_session(session_token) 15 | -- Resolve a session token to an auth token. 16 | local rd_sess_key = util.redis_key("session:" .. session_token) 17 | 18 | redis_response = rdc:exists(rd_sess_key) 19 | if not redis_response then 20 | return nil 21 | end 22 | 23 | redis_response = rdc:hgetall(rd_sess_key) 24 | if not redis_response then 25 | return nil 26 | end 27 | 28 | -- Split the scope value to get an actual list of scopes. 29 | if redis_response.scope ~= nil then 30 | redis_response.scope = redis_response.scope:split(" ") 31 | end 32 | 33 | return redis_response 34 | end 35 | 36 | function resolve_access_token(access_token, destroy) 37 | -- Resolve an access token to a session token. 38 | local rd_acc_key = util.redis_key("acctok:" .. access_token) 39 | 40 | redis_response = rdc:exists(rd_acc_key) 41 | if not redis_response then 42 | return nil 43 | end 44 | 45 | redis_response = rdc:hgetall(rd_acc_key) 46 | if not redis_response then 47 | return nil 48 | end 49 | 50 | if destroy then 51 | rdc:del(rd_acc_key) 52 | end 53 | 54 | return redis_response 55 | end 56 | 57 | function validate_token(token_response) 58 | -- Take a response from resolve_session() and validate with oauth server. 59 | local token = token_response.token 60 | local username = token_response.username 61 | local scopes = table.concat(token_response.scope, " ") 62 | 63 | local token_table = { 64 | access_token = token, 65 | username = username, 66 | scope = scopes, 67 | } 68 | token_table = ngx.encode_args(token_table) 69 | 70 | ngx.req.set_header("Content-Type", "application/x-www-form-urlencoded") 71 | local okay, oauth_res = util.func_call(ngx.location.capture, config.oauth_token_endpoint, { 72 | method = ngx.HTTP_POST, 73 | body = token_table 74 | }) 75 | 76 | local response_status = oauth_res.status 77 | if response_status ~= 200 then 78 | return false 79 | else 80 | return true 81 | end 82 | end 83 | 84 | function session_needs_checkin(user_session) 85 | -- Returns whether or not we should redirect to the auth endpoint 86 | -- to validate a users session. This solves intra-domain verification issues. 87 | local okay, session = util.func_call(resolve_session, user_session) 88 | if not okay or not session then 89 | return true 90 | end 91 | 92 | local _, last_checkin = util.func_call(get_checkin_time, user_session) 93 | if not last_checkin then 94 | last_checkin = session.created 95 | set_checkin_time(user_session, last_checkin) 96 | end 97 | 98 | local checkin_time = last_checkin + config.session_checkin 99 | local current_time = ngx.now() 100 | 101 | if current_time > checkin_time then 102 | return true 103 | else 104 | return false 105 | end 106 | end 107 | 108 | function check_session(user_session, do_validate) 109 | -- Take a user's session key and use it to validate the session. 110 | local okay, session = util.func_call(resolve_session, user_session) 111 | if not okay or not session then 112 | return false 113 | end 114 | 115 | if config.session_address_validation and session.remote_addr ~= nginx_client_address then 116 | -- We need to invalidate this session, it may have been compromised. 117 | local okay = util.func_call(invalidate_session, user_session) 118 | if not okay then 119 | ngx.log(ngx.NOTICE, "Could not invalidate user session!") 120 | return false 121 | end 122 | -- Essentially, this session is no longer valid. 123 | return false 124 | end 125 | 126 | if okay and session and do_validate then 127 | local okay, is_valid = util.func_call(validate_token, session) 128 | if is_valid then 129 | return true 130 | else 131 | ngx.log(ngx.NOTICE, "User session [" .. user_session .. "] is NOT valid!") 132 | return false 133 | end 134 | end 135 | 136 | return false 137 | end 138 | 139 | function get_cross_domain_base(server_name) 140 | for _, v in pairs(config.cookie_cross_domains) do 141 | if string.endswith(server_name, v) then 142 | return v 143 | end 144 | end 145 | 146 | return nil 147 | end 148 | 149 | function create_cdk_session(user_session) 150 | -- Create a brand new cross-domain auth key. 151 | local cross_domain_key = ngx.encode_base64(util.generate_random_string(16)) -- XXX - configurable length? 152 | local rd_cdk = util.redis_key("CDK:" .. cross_domain_key) 153 | 154 | -- Store the CDK in the user's Redis session 155 | redis_response = rdc:set(rd_cdk, user_session) 156 | if not redis_response then 157 | return nil 158 | end 159 | 160 | rdc:expire(rd_cdk, config.cookie_lifetime) 161 | 162 | return cross_domain_key 163 | end 164 | 165 | function get_cdk_session(cross_domain_key) 166 | -- Resolve a CDK to a session token. 167 | local rd_cdk = util.redis_key("CDK:" .. cross_domain_key) 168 | 169 | redis_response = rdc:exists(rd_cdk) 170 | if not redis_response then 171 | return nil 172 | end 173 | 174 | redis_response = rdc:get(rd_cdk) 175 | if not redis_response then 176 | return nil 177 | end 178 | 179 | return redis_response 180 | end 181 | 182 | function get_checkin_time(user_session) 183 | local rd_checkin_key = util.redis_key("checkin:" .. user_session) 184 | 185 | redis_response = rdc:exists(rd_checkin_key) 186 | if not redis_response then 187 | return nil 188 | end 189 | 190 | redis_response = rdc:get(rd_checkin_key) 191 | if not redis_response then 192 | return nil 193 | end 194 | 195 | return redis_response 196 | end 197 | 198 | function set_checkin_time(user_session, time_secs) 199 | local rd_checkin_key = util.redis_key("checkin:" .. user_session) 200 | 201 | local prev_time = get_checkin_time(user_session) 202 | 203 | redis_response = rdc:set(rd_checkin_key, time_secs) 204 | if not redis_response then 205 | return false 206 | end 207 | 208 | return true 209 | end 210 | 211 | function invalidate_session(user_session) 212 | local rd_session_key = util.redis_key("session:" .. user_session) 213 | local rd_checkin_key = util.redis_key("checkin:" .. user_session) 214 | 215 | rdc:pipeline(function(p) 216 | p:del(rd_session_key) 217 | p:del(rd_checkin_key) 218 | end) 219 | end 220 | 221 | function encode_return_message(target_url, message_type, message_reason) 222 | -- Takes a URL and appends an encoded message embedded in the query params. 223 | -- Example: 224 | -- Given these params: 225 | -- target_url => http://sso.example.com/auth 226 | -- message_type => error 227 | -- message_reason => An error occurred while processing your credentials. 228 | -- The function will return a new URL that looks like-ish: 229 | -- http://sso.example.com/auth?error=QW4gZXJyb3Igb2NjdXJyZWQgd2hpbGUgcHJvY2Vzc2luZyB5b3VyIGNyZWRlbnRpYWxzLg== 230 | 231 | local msg_reason = ngx.encode_base64(message_reason) 232 | ngx.req.set_uri_args({ 233 | [message_type] = msg_reason 234 | }) 235 | 236 | return target_url .. "?" .. ngx.var.args 237 | end 238 | 239 | -------------------------------------------------------------------------------- /src/util.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- util.lua 3 | -- 4 | -- Set of utils to make creating and maintaining lsso easier. 5 | -- 6 | 7 | module('util', package.seeall) 8 | 9 | -- External library imports 10 | local cjson = require "cjson" 11 | local cookie = require "resty.cookie" 12 | local redis = require "redis" 13 | local socket = require "socket" 14 | 15 | -- Useful constants. 16 | COOKIE_EXPIRED = "Thu, 01 Jan 1970 00:00:00 UTC" 17 | 18 | HTTP_UNKNOWN = -1 19 | HTTP_INFORMATIONAL = 100 20 | HTTP_SUCCESS = 200 21 | HTTP_REDIRECTION = 300 22 | HTTP_CLIENT_ERR = 400 23 | HTTP_SERVER_ERR = 500 24 | 25 | LOG_BUCKETS = {"auth", "api", "session"} 26 | 27 | -- Returns if `haystack` starts with `needle`. 28 | function string.startswith(haystack, needle) 29 | return string.sub(haystack, 1, string.len(needle)) == needle 30 | end 31 | 32 | -- Returns if `haystack` ends with `needle`. 33 | function string.endswith(haystack, needle) 34 | return needle == "" or string.sub(haystack, -(string.len(needle))) == needle 35 | end 36 | 37 | function string.chopstart(haystack, needle) 38 | if haystack:startswith(needle) then 39 | return string.sub(haystack, string.len(needle) + 1, string.len(haystack)) 40 | end 41 | 42 | return haystack 43 | end 44 | 45 | -- String split function from lua-users wiki 46 | function string:split(sSeparator, nMax, bRegexp) 47 | assert(sSeparator ~= '') 48 | assert(nMax == nil or nMax >= 1) 49 | 50 | local aRecord = {} 51 | 52 | if self:len() > 0 then 53 | local bPlain = not bRegexp 54 | nMax = nMax or -1 55 | 56 | local nField, nStart = 1, 1 57 | local nFirst,nLast = self:find(sSeparator, nStart, bPlain) 58 | while nFirst and nMax ~= 0 do 59 | aRecord[nField] = self:sub(nStart, nFirst - 1) 60 | nField = nField + 1 61 | nStart = nLast + 1 62 | nFirst, nLast = self:find(sSeparator, nStart, bPlain) 63 | nMax = nMax - 1 64 | end 65 | aRecord[nField] = self:sub(nStart) 66 | end 67 | 68 | return aRecord 69 | end 70 | 71 | -- Packs a table. 72 | function table.pack(...) 73 | return { n = select("#", ...), ... } 74 | end 75 | 76 | -- Custom Redis client commands and callbacks. 77 | redis.commands.hgetall = redis.command('hgetall', { 78 | response = function(reply, command, ...) 79 | local new_reply = {} 80 | for i = 1, #reply, 2 do new_reply[reply[i]] = reply[i + 1] end 81 | return new_reply 82 | end 83 | }) 84 | 85 | -- Random string generator. Good for generating a nonce or key. 86 | function generate_random_string(length) 87 | if length < 1 then 88 | return nil 89 | end 90 | 91 | -- Seed the random number generator. 92 | math.randomseed(tonumber(tostring(socket.gettime() * 10000):reverse())) 93 | 94 | local s = "" 95 | -- Use math.random and string.char to get random ints within visible ASCII range, 96 | -- then, turn it in to a character and append to `s`. 97 | for i = 1, length do 98 | s = s .. string.char(math.random(33, 126)) 99 | end 100 | 101 | s = ngx.encode_base64(s) 102 | if string.len(s) > length then 103 | s = string.sub(s, 1, length) 104 | end 105 | 106 | return s 107 | end 108 | 109 | -- Simplifies the request_cookie:set() routine. 110 | -- Params: 111 | -- key_name - required - name of the cookie key, wrapped by cookie_key() 112 | -- value - optional - value of the cookie, defaults to "nil", 113 | -- if nil, expires cookie immediately 114 | -- expires - optional - if true, expires cookie immediately. otherwise, is the 115 | -- expiration time (string). 116 | -- domain - optional - sets cookie domain, otherwise defaults to the 117 | -- default cookie domain 118 | -- path - optional - sets cookie path, otherwise defaults to "/" 119 | function set_cookie(key_name, value, expires, domain, path) 120 | if key_name == nil then 121 | return false 122 | end 123 | 124 | if expires == true then 125 | expires = util.COOKIE_EXPIRED 126 | elseif expires == nil then 127 | local expire_at = ngx.time() + config.cookie_lifetime 128 | expires = ngx.cookie_time(expire_at) 129 | end 130 | 131 | if value == nil then 132 | value = "nil" 133 | expires = util.COOKIE_EXPIRED 134 | end 135 | 136 | if domain == nil then 137 | domain = "." .. config.cookie_domain 138 | end 139 | 140 | if path == nil then 141 | path = "/" 142 | end 143 | 144 | local request_cookie = cookie:new() 145 | request_cookie:set({ 146 | key = util.cookie_key(key_name), 147 | value = value, 148 | path = path, 149 | domain = domain, 150 | expires = expires, 151 | }) 152 | 153 | return true 154 | end 155 | 156 | -- Returns a SAML-specced UTC timestamp 157 | function utc_time() 158 | return os.date("%Y-%m-%dT%X") 159 | end 160 | 161 | -- Checks if a table contains a key. 162 | function key_in_table(tabsrc, key) 163 | for k, v in pairs(tabsrc) do 164 | if k == key then 165 | return true 166 | end 167 | end 168 | 169 | return false 170 | end 171 | 172 | -- Checks if a value is in a table. If so, return the key. 173 | function value_in_table(tabsrc, val) 174 | for k, v in pairs(tabsrc) do 175 | if v == val then 176 | return k 177 | end 178 | end 179 | 180 | return nil 181 | end 182 | 183 | -- Merges items from table `from` into `onto`, modifying `onto` directly. 184 | function merge_tables(from, onto) 185 | for k, v in pairs(from) do 186 | if type(v) == "table" then 187 | if type(onto[k] or nil) == "table" then 188 | merge_tables(v, onto[k]) 189 | else 190 | onto[k] = v 191 | end 192 | else 193 | onto[k] = v 194 | end 195 | end 196 | 197 | return onto 198 | end 199 | 200 | function table_tostring(tbl) 201 | s = "" 202 | for k, v in pairs(tbl) do 203 | if type(k) ~= "string" then 204 | k = tostring(k) 205 | end 206 | if type(v) == "table" then 207 | s = s .. "; " .. k .. " -> " table_tostring(v) 208 | else 209 | s = s .. "; " .. k .. " -> " .. tostring(v) 210 | end 211 | end 212 | 213 | return s 214 | end 215 | 216 | -- Function wrapper for Raven. 217 | function func_call(func, ...) 218 | if config.debug_enabled then 219 | return rvn:call(func, ...) 220 | else 221 | return pcall(func, ...) 222 | end 223 | end 224 | 225 | -- Function calls to simplify Redis logging. 226 | -- Parameters: 227 | -- log_facility - (ie., auth, session) part of the list key (lsso:log:auth) 228 | -- ... - lines to log to Redis 229 | function log_redis(log_facility, ...) 230 | local args = table.pack(...) 231 | local log_key = redis_key("log:" .. log_facility) 232 | local message_meta = { 233 | timestamp = ngx.now(), 234 | phase = ngx.get_phase() 235 | } 236 | for i=1, args.n do 237 | local arg = args[i] 238 | if type(arg) == "table" then 239 | if arg["context"] == "logging" then 240 | -- This is addition logging context, merge to meta. 241 | merge_tables(arg, message_meta) 242 | args[i] = nil 243 | break 244 | end 245 | end 246 | end 247 | if ngx.status ~= nil then 248 | -- There *should* be an active request going now. 249 | merge_tables({ 250 | request = { 251 | status = ngx.status, 252 | uri = ngx.var.uri, 253 | method = ngx.req.get_method(), 254 | headers = ngx.req.get_headers() 255 | } 256 | }, message_meta) 257 | end 258 | local replies = rdc:pipeline(function(redis_pipe) 259 | for i=1, args.n do 260 | if args[i] == nil then 261 | goto continue 262 | end 263 | local message_data = { 264 | message = args[i] 265 | } 266 | merge_tables(message_meta, message_data) 267 | message_data = cjson.encode(message_data) 268 | func_call(redis_pipe.rpush, rdc, log_key, message_data) 269 | ::continue:: 270 | end 271 | end) 272 | 273 | return replies 274 | end 275 | 276 | -- Wrapper function for log_redis("auth", ...) 277 | function auth_log(...) 278 | if not config.auth_logging then 279 | return nil 280 | end 281 | 282 | return log_redis("auth", ...) 283 | end 284 | 285 | -- Wrapper function for log_redis("session", ...) 286 | function session_log(...) 287 | if not config.session_logging then 288 | return nil 289 | end 290 | 291 | return log_redis("session", ...) 292 | end 293 | 294 | -- Wrapper function for log_redis("api", ...) 295 | function api_log(...) 296 | if not config.api_logging then 297 | return nil 298 | end 299 | 300 | return log_redis("api", ...) 301 | end 302 | 303 | -- Convenience functions for Redis keys and cookies 304 | function redis_key(key_name) 305 | return config.redis_key_prefix .. key_name 306 | end 307 | 308 | function cookie_key(key_name) 309 | return config.cookie_prefix .. key_name 310 | end 311 | 312 | function get_cookie(cookie_name) 313 | local cookie = "cookie_" .. cookie_name 314 | return ngx.var[cookie] 315 | end 316 | 317 | -- Functions for getting values from tables in a protected manner. 318 | function prot_table_get(tbl, val, default) 319 | if not tbl then 320 | return nil 321 | end 322 | 323 | if not val then 324 | return nil 325 | end 326 | 327 | local okay, val = pcall(unprot_table_get, tbl, val) 328 | if not okay then 329 | -- More than likely could not access this key, ret default 330 | return default 331 | else 332 | return val 333 | end 334 | end 335 | 336 | function unprot_table_get(tbl, val) 337 | return tbl[val] 338 | end 339 | 340 | -- Determines the class of a given HTTP status 341 | -- Returns the status class of the error: 342 | -- HTTP_INFORMATIONAL => 1xx 343 | -- HTTP_SUCCESS => 2xx 344 | -- HTTP_REDIRECTION => 3xx 345 | -- HTTP_CLIENT_ERR => 4xx 346 | -- HTTP_SERVER_ERR => 5xx 347 | -- HTTP_UNKNOWN => -1 348 | function http_status_class(status) 349 | if not status then 350 | return HTTP_UNKNOWN 351 | end 352 | 353 | if status >= HTTP_INFORMATIONAL and status < HTTP_SUCCESS then 354 | return HTTP_INFORMATIONAL 355 | elseif status >= HTTP_SUCCESS and status < HTTP_REDIRECTION then 356 | return HTTP_SUCCESS 357 | elseif status >= HTTP_REDIRECTION and status < HTTP_CLIENT_ERR then 358 | return HTTP_REDIRECTION 359 | elseif status >= HTTP_SERVER_ERR and status < (HTTP_SERVER_ERR + 100) then 360 | return HTTP_SERVER_ERR 361 | else 362 | return HTTP_UNKNOWN 363 | end 364 | end 365 | 366 | function key_length(redis_key) 367 | -- Pull the length of the list in Redis. 368 | redis_response = rdc:exists(redis_key) 369 | if not redis_response then 370 | return 0 371 | end 372 | 373 | redis_response = rdc:llen(redis_key) 374 | if not redis_response then 375 | return 0 376 | end 377 | 378 | return redis_response 379 | end 380 | 381 | function log_fetch(bucket, page, limit) 382 | if value_in_table(LOG_BUCKETS, bucket) == nil then 383 | return nil 384 | end 385 | 386 | if page == nil then 387 | page = 0 388 | end 389 | 390 | if limit == nil then 391 | limit = tonumber(config.log_paginate_count or 20) or 20 392 | end 393 | 394 | local key = redis_key("log:" .. bucket) 395 | 396 | -- Pull a "page" of logs 397 | local length = key_length(key) 398 | if length == 0 then 399 | return {} 400 | end 401 | 402 | local page_start = page * limit 403 | local page_end = page_start + limit - 1 404 | 405 | redis_response = rdc:lrange(key, page_start, page_end) 406 | if not redis_response then 407 | return {} 408 | else 409 | resp = {} 410 | for _, val in ipairs(redis_response) do 411 | local real = unfurl_json(val) 412 | real._raw = unfurl_json(val, true) 413 | table.insert(resp, real) 414 | end 415 | return resp 416 | end 417 | end 418 | 419 | -- JSON data that gets put in to Redis tends to come back out in a strange 420 | -- format. This function takes the JSON output and fixes it up for decoding. 421 | function unfurl_json(jsonstr, no_decode) 422 | jsonstr = string.gsub(jsonstr, "\\/", "/") 423 | if not no_decode then 424 | return cjson.decode(jsonstr) 425 | else 426 | return jsonstr 427 | end 428 | end 429 | --------------------------------------------------------------------------------