packages feed

sproxy (empty) → 0.9.4

raw patch · 16 files changed

+1251/−0 lines, 16 filesdep +SHAdep +aesondep +attoparsecsetup-changed

Dependencies added: SHA, aeson, attoparsec, base, base64-bytestring, bytestring, containers, data-default, docopt, entropy, http-conduit, http-kit, http-types, interpolatedstring-perl6, logging-facade, logsink, network, postgresql-simple, raw-strings-qq, resource-pool, split, string-conversions, time, tls, unix, utf8-string, x509, yaml

Files

+ ChangeLog.md view
@@ -0,0 +1,34 @@+0.9.4+=====++* Combine the multiple header fields into one "field-name: field-value" pair+  with a comma-separated list for the field-value (Instead of removing duplicates).+* `sproxy [ -h | --help ]` works (using [docopt](https://hackage.haskell.org/package/docopt))+* Stop counting client parsing failures as errors.+++0.9.3+=====++* Made some options in configuration file optional with reasonable default values:+  - `log_target`: `stderr`+  - `listen`: `443`+  - `redirect_http_to_https`: yes if `listen == 443`+  - `backend_address`: `"127.0.0.1"`+  - `backend_port`: `8080`+* Allow backend at UNIX socket: new option `backend_socket`.+* Removed tests (unsupported).+* Don't build Sproxy library.+++0.9.2+=====++* Deny SSLv3.++* Removed the `auth_token_key` option from the config file.+  The token is generated randomly on startup.+	Restarting sproxy invalidates existing sessions.++* Added `ChangeLog.md`+
+ LICENSE view
@@ -0,0 +1,19 @@+Copyright (c) 2013-2016 Zalora South East Asia Pte. Ltd++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in+all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN+THE SOFTWARE.
+ README.md view
@@ -0,0 +1,154 @@+# sproxy - secure proxy - HTTP proxy for authenticating users via Google OAuth2++## Motivation++Why use a proxy for doing OAuth? Isn't that up to the application?++ * sproxy is secure by default.  No requests make it to the web server if they+   haven't been explicitly whitelisted.+ * sproxy is independent.  Any web application written in any language can use+   it.++## How it Works++When an HTTP client makes a request, sproxy checks for a *session cookie*.  If it+doesn't exist (or it's invalid), it redirects the client to a Google+authentication page.  The user is then prompted to allow the application to+access information on the user (email address).  If the user proceeds, they're+redirected back to sproxy with a code from Google.  We then take that code and+send it to Google ourselves to get back an access token.  Then, we use the+access token to make another call to Google, this time to their user info API+to retrieve the user's email address.  Finally, we store the the email address+in a session cookie: signed with a hash to prevent tampering, set for HTTP only (to+prevent malicious JavaScript from reading it), and set it for secure (since we+don't want it traveling over plaintext HTTP connections).++From that point on, when sproxy detects a valid session cookie it extracts the+email, checks it against the access rules, and relays the request to the+back-end server (if allowed).++## Logout++Hitting the endpoint `/sproxy/logout` will invalidate the session+cookie.  The user will be redirected to `/` after logout.  The+query parameter `state` can be provided to specify an alternate redirect path+(the path has to be percent-encoded, e.g. with `urlEncode` from+`Network.HTTP.Types.URI`).++## Robots++Since all sproxied resources are private, it doesn't make sense for web crawlers+to try to index them. In fact, crawlers will index only the Google authentication+page. To prevent this, sproxy returns the following for `/robots.txt`:++```+User-agent: *+Disallow: /+```++## Permissions system++Permissions are stored in a PostgreSQL database. See sproxy.sql for details.+Here are the main concepts:++- A `group` is identified by a name. Every group has+  - members (identified by email address, through `group_member`) and+  - associated privileges (through `group_privilege`).+- A `privilege` is identified by a name _and_ a domain. It has associated rules+  (through `privilege_rule`) that define what the privilege gives access to.+- A `rule` is a combination of sql patterns for a `domain`, a `path` and an+  HTTP `method`. A rule matches an HTTP request, if all of these components+  match the respective attributes of the request. However of all the matching+  rules only the rule with the longest `path` pattern will be used to determine+  whether a user is allowed to perform a request. This is often a bit+  surprising, please see the following example:++### Privileges example++Consider this `group_privilege` and `privilege_rule` relations:++group            | privilege | domain+---------------- | --------- | -----------------+`readers`        | `basic`   | `wiki.zalora.com`+`readers`        | `read`    | `wiki.zalora.com`+`editors`        | `basic`   | `wiki.zalora.com`+`editors`        | `read`    | `wiki.zalora.com`+`editors`        | `edit`    | `wiki.zalora.com`+`administrators` | `basic`   | `wiki.zalora.com`+`administrators` | `read`    | `wiki.zalora.com`+`administrators` | `edit`    | `wiki.zalora.com`+`administrators` | `admin`   | `wiki.zalora.com`++privilege   | domain            | path           | method+----------- | ----------------- | -------------- | ------+`basic`     | `wiki.zalora.com` | `/%`           | `GET`+`read`      | `wiki.zalora.com` | `/wiki/%`      | `GET`+`edit`      | `wiki.zalora.com` | `/wiki/edit/%` | `%`+`admin`     | `wiki.zalora.com` | `/admin/%`     | `%`++With this setup, everybody (that is `readers`, `editors` and `administrators`s)+will have access to e.g. `/imgs/logo.png` and `/favicon.ico`, but only+administrators will have access to `/admin/index.php`, because the longest+matching path pattern is `/admin/%` and only `administrator`s have the `admin`+privilege.++Likewise `readers` have no access to e.g. `/wiki/edit/delete_everything.php`.+++## HTTP headers passed to the back-end server:++header               | value+-------------------- | -----+`From:`              | visitor's email address+`X-Groups:`          | all groups that granted access to this resource, separated by commas (see the note below)+`X-Given-Name:`      | the visitor's given (first) name+`X-Family-Name:`     | the visitor's family (last) name+`X-Forwarded-Proto:` | the visitor's protocol of an HTTP request, always `https`+`X-Forwarded-For`    | the visitor's IP address (added to the end of the list if header is already present in client request)+++`X-Groups` denotes an intersection of the groups the visitor belongs to and the groups that granted access:++Visitor's groups | Granted groups | `X-Groups`+---------------- | -------------- | ---------+all              | all, devops    | all+all, devops      | all            | all+all, devops      | all, devops    | all,devops+all, devops      | devops         | devops+devops           | all, devops    | devops+devops           | all            | Access denied++## Configuration File++By default `sproxy` will read its configuration from `config/sproxy.yml`.  You+can specify a custom path with:++```+sproxy --config /path/to/sproxy.yml+```++## Development++```+$ cp config/sproxy.yml.example config/sproxy.yml+```++Make sure that you have the following entry in `/etc/hosts`:++```+127.0.0.1       dev.zalora.com+```+++### Create OAuth credentials++Create a project in the [Google Developers Console](https://console.developers.google.com/project).++ - visit *APIs & auth* -> *Credentials*+ - select *CREATE NEW CLIENT ID*+ - use `https://dev.zalora.com` as *Authorized JavaScript origins*+ - use `https://dev.zalora.com/sproxy/oauth2callback` as *Authorized redirect URI*++Put the `Client ID` in `config/sproxy.yml` and the `Client secret` in a file+called `config/client_secret`.+
+ Setup.lhs view
@@ -0,0 +1,3 @@+#!/usr/bin/env runhaskell+> import Distribution.Simple+> main = defaultMain
+ sproxy.cabal view
@@ -0,0 +1,60 @@+name: sproxy+version: 0.9.4+synopsis: HTTP proxy for authenticating users via Google OAuth2+license: MIT+license-file: LICENSE+copyright: 2013-2016, Zalora South East Asia Pte. Ltd+author: Chris Forno <jekor@jekor.com>+maintainer: Igor Pashev <pashev.igor@gmail.com>+category: Web+build-type: Simple+extra-source-files: README.md ChangeLog.md+cabal-version: >=1.10+data-files: sproxy.sql++executable sproxy+  main-is: Main.hs+  other-modules:+      Authenticate+    , Authorize+    , ConfigFile+    , Cookies+    , HTTP+    , Logging+    , Proxy+    , Type+    , Util++  hs-source-dirs: src+  default-language: Haskell2010+  ghc-options: -Wall -static+  build-depends:+      base >= 4.8 && < 5+    , SHA+    , aeson >= 0.7.0.4+    , attoparsec+    , base64-bytestring+    , bytestring+    , containers >= 0.5+    , data-default+    , docopt >= 0.7+    , entropy+    , http-conduit >= 2.1.8+    , http-kit >= 0.5+    , http-types >= 0.8.5+    , interpolatedstring-perl6+    , logging-facade+    , logsink+    , network+    , postgresql-simple+    , raw-strings-qq >= 1.1+    , resource-pool+    , split+    , string-conversions+    , time+    , tls >= 1.3.3+    , unix+    , utf8-string+    , x509+    , yaml >= 0.8+
+ sproxy.sql view
@@ -0,0 +1,148 @@+-- CREATE DATABASE sproxy;+-- CREATE ROLE sproxy WITH LOGIN;+-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO sproxy;++CREATE TABLE IF NOT EXISTS "group" (+  "group" TEXT NOT NULL PRIMARY KEY+);++-- | group        |+-- |--------------|+-- | data science |+-- | devops       |+-- | all          |+-- | regional     |+-- | SG HQ        |++CREATE EXTENSION IF NOT EXISTS citext;++CREATE TABLE IF NOT EXISTS group_member (+  "group" TEXT REFERENCES "group" ("group") ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,+  email citext NOT NULL,+  PRIMARY KEY ("group", email)+);++-- | group        | email                  |+-- |--------------+------------------------|+-- | data science | blah@zalora.com        |+-- | data science | foo@zalora.com         |+-- | devops       | devops1@zalora.com     |+-- | devops       | devops2@zalora.com     |+-- | all          | %@zalora.com           |+-- | all          | %@zalora.sg            |+-- | all          | %@zalora.vn            |+-- | all          | %@zalora.com.hk        |+-- | all          | %@zalora.com.my        |+-- | all          | %@zalora.com.ph        |+-- | all          | %@zalora.co.id         |+-- | all          | %@zalora.co.th         |+-- | regional     | %@zalora.com           |+-- | SG           | %@zalora.sg            |++-- Find out which groups a user (email address) belongs to:+-- SELECT "group" FROM group_member WHERE 'email.address' LIKE email++CREATE TABLE IF NOT EXISTS domain (+  domain TEXT NOT NULL PRIMARY KEY+);++-- | domain                |+-- |-----------------------|+-- | app1.zalora.com       |+-- | app2.zalora.com       |+-- | app3.zalora.com       |++CREATE TABLE IF NOT EXISTS privilege (+  "domain" TEXT REFERENCES domain (domain) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,+  privilege TEXT NOT NULL,+  PRIMARY KEY ("domain", privilege)+);++-- | domain                | privilege  |+-- |-----------------------+------------|+-- | app3.zalora.com       | view       |+-- | app3.zalora.com       | export     |+-- | app1.zalora.com       | list users |+-- | app1.zalora.com       | add users  |++CREATE TABLE IF NOT EXISTS privilege_rule (+  "domain" TEXT NOT NULL,+  privilege TEXT NOT NULL,+  "path" TEXT NOT NULL,+  "method" TEXT NOT NULL,+  FOREIGN KEY ("domain", privilege) REFERENCES privilege ("domain", privilege) ON UPDATE CASCADE ON DELETE CASCADE,+  PRIMARY KEY ("domain", "path", "method")+);++-- | domain                | privilege  | path      | method |+-- |-----------------------+------------+-----------+--------|+-- | app3.zalora.com       | view       | /%        | %      |+-- | app3.zalora.com       | export     | /export/% | %      |+-- | app1.zalora.com       | list users | /users    | GET    |+-- | app1.zalora.com       | list users | /user/%   | GET    |+-- | app1.zalora.com       | add users  | /users    | POST   |++CREATE TABLE IF NOT EXISTS group_privilege (+  "group" TEXT REFERENCES "group" ("group") ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,+  "domain" TEXT NOT NULL,+  privilege TEXT NOT NULL,+  FOREIGN KEY ("domain", privilege) REFERENCES privilege ("domain", privilege) ON UPDATE CASCADE ON DELETE CASCADE,+  PRIMARY KEY ("group", "domain", privilege)+);++-- | group        | domain                | privilege  |+-- |--------------+-----------------------+------------|+-- | data science | app3.zalora.com       | view       |+-- | data science | app3.zalora.com       | export     |+-- | all          | app1.zalora.com       | list users |+-- | devops       | app1.zalora.com       | add users  |++-- Check if the user is authorized for the request. Let's break it+-- down for understanding:++-- The privilege required to access a URL is the most specific+-- (longest) match. To determine length, we look at the number of+-- slashes in the URL pattern (number of path components).+--+-- SELECT p.privilege FROM privilege p+-- INNER JOIN privilege_rule pr ON pr."domain" = p."domain" AND pr.privilege = p.privilege+-- WHERE 'app3.zalora.com' LIKE pr."domain" AND '/export/test' LIKE "path" AND 'GET' ILIKE "method"+-- ORDER by array_length(regexp_split_to_array("path", '/'), 1) DESC LIMIT 1+--+-- To get the groups that grant the user access, put that in a subquery:+--+-- SELECT gp."group" FROM group_privilege gp+-- INNER JOIN group_member gm ON gm."group" = gp."group"+-- WHERE 'blah@zalora.com' LIKE email+-- AND 'app3.zalora.com' LIKE "domain"+-- AND privilege IN (+--   SELECT p.privilege FROM privilege p+--   INNER JOIN privilege_rule pr ON pr."domain" = p."domain" AND pr.privilege = p.privilege+--   WHERE 'app3.zalora.com' LIKE pr."domain" AND '/export/test' LIKE "path" AND 'GET' ILIKE "method"+--   ORDER by array_length(regexp_split_to_array("path", '/'), 1) DESC LIMIT 1+-- )+--+-- If you just want to know if a user has access or not, you can+-- change the first line to:+--+-- SELECT COUNT(*) > 0 FROM group_privilege gp+--+-- Note for the future: If you want to support wildcards that match+-- only a single path component (e.g. app1.zalora.com/user/:/email),+-- you could try something like:+--+-- WHERE 'url' ~ regexp_replace(url, ':', '[^/]+')+--+-- But you'd also have to escape any regexp special characters in the+-- url as well (i.e. dots).++-- Example data for development:+/*+  INSERT INTO domain (domain) VALUES ('dev.zalora.com');+  INSERT INTO "group" ("group") VALUES ('dev');+  INSERT INTO group_member ("group", email) VALUES ('dev', '%');+  INSERT INTO privilege (domain, privilege) VALUES ('dev.zalora.com', 'full');+  INSERT INTO group_privilege ("group", domain, privilege) VALUES ('dev', 'dev.zalora.com', 'full');+  INSERT INTO privilege_rule (domain, privilege, path, method) VALUES ('dev.zalora.com', 'full', '%', '%');+*/+
+ src/Authenticate.hs view
@@ -0,0 +1,198 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE FlexibleContexts #-}++module Authenticate (+  AuthConfig(..)+, AuthToken(..)+, validAuth+, redirectForAuth+, authenticate+, logout++-- exported to silence warnings+, AccessToken(..)+) where++import           Control.Applicative+import           Control.Exception+import           Text.Read (readMaybe)+import           Data.Monoid+import           Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as B8+import qualified Data.ByteString.UTF8 as UTF8+import qualified Data.ByteString.Lazy.Char8 as BL8+import           Data.String.Conversions (cs)+import           Data.List.Split (splitOn)+import           Data.Aeson+import           Network.HTTP.Types+import           System.Posix.Types (EpochTime)+import           System.Posix.Time (epochTime)+import           Data.Digest.Pure.SHA (hmacSha1, showDigest)+import           Network.HTTP.Conduit (simpleHttp, parseUrl, httpLbs, RequestBody(..))+import qualified Network.HTTP.Conduit as HTTP+import           Network.HTTP.Toolkit++import qualified System.Logging.Facade as Log++import           Cookies+import           HTTP++data AuthConfig = AuthConfig {+  authConfigCookieDomain :: String+, authConfigCookieName :: String+, authConfigClientID :: String+, authConfigClientSecret :: String+, authConfigAuthTokenKey :: String+} deriving (Eq, Show)++data AccessToken = AccessToken {+  accessToken :: String+, expiresIn :: Integer+, tokenType :: String+} deriving (Eq, Show)++instance FromJSON AccessToken where+  parseJSON (Object v) = AccessToken+    <$> v .: "access_token"+    <*> v .: "expires_in"+    <*> v .: "token_type"+  parseJSON _ = empty++data AuthToken = AuthToken {+  authEmail  :: String+, authName   :: (String, String)+, authExpiry :: EpochTime+, authDigest :: String -- HMAC hash+}++-- Here is the format of the actual cookie we send to the client.+instance Show AuthToken where+  show a = authEmail a ++ ":" ++ authNameString (authName a) ++ ":" ++ show (authExpiry a) ++ ":" ++ authDigest a+    where authNameString (given, family) = given ++ ":" ++ family++instance Read AuthToken where+  readsPrec _ s = case splitOn ":" s of+    [email, given, family, expire, digest] -> [(AuthToken email (given, family) (read expire) digest, "")]+    _ -> []++data UserInfo = UserInfo {+  userEmail :: String+, userGivenName :: String+, userFamilyName :: String+} deriving (Eq, Show)++instance FromJSON UserInfo where+  parseJSON (Object v) = UserInfo+    <$> v .: "email"+    <*> v .: "given_name"+    <*> v .: "family_name"+  parseJSON _ = empty++redirectUri :: ByteString -> ByteString+redirectUri baseUri = baseUri <> "/sproxy/oauth2callback"++authUrl :: ByteString -> ByteString -> AuthConfig -> ByteString+authUrl path baseUri c = mconcat [+    "https://accounts.google.com/o/oauth2/auth?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile&"+  , "state=", urlEncode True path+  , "&redirect_uri=", redirectUri baseUri+  , "&response_type=code&client_id=", B8.pack (authConfigClientID c)+  , "&approval_prompt=force&access_type=offline"+  ]++redirectForAuth :: AuthConfig -> ByteString -> ByteString -> IO (Response BodyReader)+redirectForAuth c path baseUri = mkResponse found302 [("Location", authUrl path baseUri c)] ""++authenticate :: AuthConfig -> ByteString -> ByteString -> ByteString -> IO (Response BodyReader)+authenticate config baseUri path code = do+  Log.info ("authentication request with code " ++ show code)+  tokenRes <- try $ post "https://accounts.google.com/o/oauth2/token"+              (cs $ "code=" ++ cs code ++ "&client_id=" ++ clientID+                ++ "&client_secret=" ++ clientSecret ++ "&redirect_uri="+                ++ cs (redirectUri baseUri) ++ "&grant_type=authorization_code")+  case tokenRes of+    Left err -> authenticationFailed ("error while authenticating: " ++ show (err :: HTTP.HttpException))+    Right resp ->+      case decode (HTTP.responseBody resp) of+        Nothing ->+          authenticationFailed "Received an invalid response from Google's authentication server."+        Just token -> do+          infoRes <- try $ simpleHttp ("https://www.googleapis.com/oauth2/v1/userinfo?access_token=" ++ accessToken token)+          case infoRes of+            Left err -> authenticationFailed ("error while retrieving user info: " ++ show (err :: HTTP.HttpException))+            Right body ->+              case decode body of+                Nothing -> authenticationFailed "Received an invalid user info response from Google's authentication server."+                Just userInfo -> do+                  clientToken <- authToken authTokenKey (userEmail userInfo) (userGivenName userInfo, userFamilyName userInfo)+                  let cookie = setCookie cookieDomain cookieName (show clientToken) authShelfLife+                  mkResponse found302 [("Location", baseUri <> urlDecode False path), ("Set-Cookie", UTF8.fromString cookie)] ""+  where+    cookieDomain = authConfigCookieDomain config+    cookieName = authConfigCookieName config+    clientID = authConfigClientID config+    clientSecret = authConfigClientSecret config+    authTokenKey = authConfigAuthTokenKey config++logout :: AuthConfig -> ByteString -> IO (Response BodyReader)+logout config url = do+  let cookie = invalidateCookie cookieDomain cookieName+  mkResponse found302 [("Location", url), ("Set-Cookie", UTF8.fromString cookie)] ""+  where+    cookieDomain = authConfigCookieDomain config+    cookieName = authConfigCookieName config++post :: String -> ByteString -> IO (HTTP.Response BL8.ByteString)+post url body = do+  r' <- parseUrl url+  let r = r' { HTTP.method = "POST"+             , HTTP.requestBody = RequestBodyBS body+             , HTTP.requestHeaders+               = [ ("Content-Type" , "application/x-www-form-urlencoded")+                 ]+             }+  manager <- HTTP.newManager HTTP.tlsManagerSettings+  httpLbs r manager++validAuth :: AuthConfig -> String -> IO (Maybe AuthToken)+validAuth config token =+  case readMaybe token of+    Nothing -> return Nothing+    Just t -> do+      now <- epochTime+      if tokenDigest key t == authDigest t && authExpiry t > now+        then return $ Just t+        else return Nothing+  where+    key = authConfigAuthTokenKey config++-- | Create an AuthToken with the default expiration time, automatically+-- calculating the digest.+authToken :: String -> String -> (String, String) -> IO AuthToken+authToken key email name = do+  now <- epochTime+  let expires = now + authShelfLife+      digest = tokenDigest key AuthToken {+          authEmail = email+        , authName = name+        , authExpiry = expires+        , authDigest = ""+        }+      token = AuthToken {+          authEmail  = email+        , authName = name+        , authExpiry = expires+        , authDigest = digest+        }+  return token++-- | This generates the HMAC digest of the auth token using SHA1.+-- Eventually, we need to rotate the key used to generate the HMAC, while still+-- storing old keys long enough to use them for any valid login session. Without+-- this, authentication is less secure.+tokenDigest :: String -> AuthToken -> String+tokenDigest key a = showDigest $ hmacSha1 (BL8.pack key) (BL8.pack token)+  where token = show (authEmail a) ++ show (authExpiry a)++authShelfLife :: EpochTime+authShelfLife = 30 * 24 * 60 * 60 -- 30 days
+ src/Authorize.hs view
@@ -0,0 +1,67 @@+{-# LANGUAGE QuasiQuotes #-}+module Authorize (+  AuthorizeAction+, Email+, Domain+, RequestPath+, Group++, withDatabaseAuthorizeAction+) where++import           Control.Exception+import           Data.ByteString (ByteString)+import           Network.HTTP.Types+import           Text.InterpolatedString.Perl6 (q)+import           Database.PostgreSQL.Simple+import           Data.Pool+import           Data.Time.Clock+import           Data.String.Conversions (cs)++type AuthorizeAction = Email -> Domain -> RequestPath -> Method -> IO [Group]++type Email = String+type Domain = ByteString+type RequestPath = ByteString+type Group = String++type ConnectionPool = Pool Connection++createConnectionPool :: String -> IO ConnectionPool+createConnectionPool database = createPool open close 1 connectionIdleTime connectionPoolSize+  where+    open :: IO Connection+    open = connectPostgreSQL (cs database)++    connectionPoolSize :: Int+    connectionPoolSize = 5++    connectionIdleTime :: NominalDiffTime+    connectionIdleTime = 5++destroyConnectionPool :: ConnectionPool -> IO ()+destroyConnectionPool = destroyAllResources++withConnectionPool :: String -> (ConnectionPool -> IO a) -> IO a+withConnectionPool database = bracket (createConnectionPool database) destroyConnectionPool++withDatabaseAuthorizeAction :: String -> (AuthorizeAction -> IO a) -> IO a+withDatabaseAuthorizeAction database action = withConnectionPool database $ \pool -> do+  let authorizeAction :: AuthorizeAction+      authorizeAction domain email path method = withResource pool $ \conn -> authorizedGroups conn domain email path method+  action authorizeAction++authorizedGroups :: Connection -> AuthorizeAction+authorizedGroups db email domain path method =+  fmap fromOnly `fmap` query db [q|+SELECT gp."group" FROM group_privilege gp+INNER JOIN group_member gm ON gm."group" = gp."group"+WHERE ? LIKE email+AND ? LIKE "domain"+AND privilege IN (+  SELECT p.privilege FROM privilege p+  INNER JOIN privilege_rule pr ON pr."domain" = p."domain" AND pr.privilege = p.privilege+  WHERE ? LIKE pr."domain" AND ? LIKE "path" AND ? ILIKE "method"+  ORDER by array_length(regexp_split_to_array("path", '/'), 1) DESC LIMIT 1+)+|] (email, domain, domain, path, method)
+ src/ConfigFile.hs view
@@ -0,0 +1,73 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# OPTIONS_GHC -fno-warn-orphans #-}++module ConfigFile where++import           Control.Applicative+import           Data.Char+import           Data.Word+import           Text.Read+import           System.IO+import           System.Exit+import           Data.Aeson+import           Data.Yaml+import           System.Logging.LogSink.Config++withConfigFile :: FilePath -> (ConfigFile -> IO a) -> IO a+withConfigFile configFile action = do+  c <- decodeFileEither configFile+  case c of+    Left err -> do+      hPutStrLn stderr ("error parsing configuration file " ++ configFile ++ ": " ++ show err)+      exitFailure+    Right config -> action config++data ConfigFile = ConfigFile {+  cfLogLevel :: LogLevel+, cfLogTarget :: LogTarget+, cfListen :: Word16+, cfRedirectHttpToHttps :: Maybe Bool+, cfCookieDomain :: String+, cfCookieName :: String+, cfClientID :: String+, cfClientSecretFile :: FilePath+, cfSslKey :: FilePath+, cfSslCerts :: FilePath+, cfDatabase :: String+, cfBackendAddress :: String+, cfBackendPort :: Word16+, cfBackendSocket :: Maybe String+} deriving (Eq, Show)++instance FromJSON ConfigFile where+  parseJSON (Object m) = ConfigFile <$>+        (m .: "log_level" >>= parseLogLevel)+    <*> (m .:? "log_target" .!= "stderr" >>= parseLogTarget)+    <*> m .:? "listen" .!= 443+    <*> m .:? "redirect_http_to_https"+    <*> m .: "cookie_domain"+    <*> m .: "cookie_name"+    <*> m .: "client_id"+    <*> m .: "client_secret"+    <*> m .: "ssl_key"+    <*> m .: "ssl_certs"+    <*> m .: "database"+    <*> m .:? "backend_address" .!= "127.0.0.1"+    <*> m .:? "backend_port" .!= 8080+    <*> m .:? "backend_socket"+  parseJSON _ = empty++deriving instance Read LogLevel++parseLogLevel :: String -> Parser LogLevel+parseLogLevel s = (maybe err return . readMaybe . map toUpper) s+  where+    err = fail ("invalid log_level " ++ show s)++parseLogTarget :: String -> Parser LogTarget+parseLogTarget s = case s of+  "stderr" -> return StdErr+  "syslog" -> return SysLog+  _ -> fail ("invalid log_target " ++ show s)+
+ src/Cookies.hs view
@@ -0,0 +1,83 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE FlexibleContexts #-}+module Cookies (+  Name+, Value+, parseCookies+, removeCookie+, formatCookies+, setCookie+, invalidateCookie+) where++import           Data.String+import           Control.Applicative+import qualified Data.ByteString as BS+import Data.Char hiding (isSpace)+import Data.List (partition, intercalate)+import Network.HTTP.Types.Header (Header, hCookie)+import System.Posix.Types (EpochTime)+import Data.Attoparsec.ByteString.Char8++type Name = String+type Value = String++removeCookie :: String -> [(Name, Value)] -> Maybe (Value, [(Name, Value)])+removeCookie name cookies = case partition ((== name) . fst) cookies of+  ((_, x):_, xs) -> Just (x, xs)+  _ -> Nothing++setCookie :: String -> String -> String -> EpochTime -> String+setCookie domain name value maxAge = mkCookie domain [+    (name, value)+  , ("Max-Age", show maxAge)+  ]++invalidateCookie :: String -> String -> String+invalidateCookie domain name = mkCookie domain [+    (name, "deleted")+  , ("expires", "Thu, 01 Jan 1970 00:00:00 GMT")+  ]++mkCookie :: String -> [(String, String)] -> String+mkCookie domain = intercalate "; " . map format . (++ defaults) . map (fmap Just)+  where+    defaults = [+        ("Domain", Just domain)+      , ("path", Just "/")+      , ("HttpOnly", Nothing)+      , ("Secure", Nothing)+      ]+    format (name, mValue) = maybe name (\value -> name ++ "=" ++ value) mValue+++formatCookies :: [(Name, Value)] -> BS.ByteString+formatCookies = mconcat . intercalate ["; "] . map formatCookie+  where+    formatCookie (name, value) = [fromString name, "=", fromString value]++parseCookies :: [Header] -> [(Name, Value)]+parseCookies = foldr headerToCookies []++headerToCookies :: Header -> [(Name, Value)] -> [(Name, Value)]+headerToCookies (name, val) acc+  | name == hCookie = case parseOnly cookies val of+      Left{}  -> acc+      Right x -> x ++ acc+  | otherwise = acc+  where+   cookies :: Parser [(Name, Value)]+   cookies = sepBy1 cookie (";" *> skipSpace)++   cookie :: Parser (Name, Value)+   cookie = (,) <$> word <*> (skipSpace *> "=" *> skipSpace *> value)++   value :: Parser String+   value = quotedstring <|> many1 (satisfy (/= ';')) <|> return ""++quotedstring :: Parser String+quotedstring = char '"' *> many (satisfy  (/= '"')) <* char '"'++word :: Parser String+word = many1 (satisfy (\x -> isAlphaNum x || x=='_' || x=='.' || x=='-' || x==':'))+
+ src/HTTP.hs view
@@ -0,0 +1,72 @@+{-# LANGUAGE OverloadedStrings, QuasiQuotes #-}+module HTTP (+  hostHeaderMissing+, authenticationFailed+, accessDenied+, badRequest+, mkResponse+, mkTextResponse+, mkHtmlResponse+) where++import           Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as B+import           Network.HTTP.Types+import           Network.HTTP.Toolkit+import           Network.HTTP.Toolkit.Body+import           Text.InterpolatedString.Perl6 (qc)++import qualified System.Logging.Facade as Log++hostHeaderMissing :: Request a -> IO (Response BodyReader)+hostHeaderMissing r = do+  Log.warn $ "Host header missing for request: " ++ show (requestMethod r, requestPath r, requestHeaders r)+  mkTextResponse badRequest400 "400 Bad Request"++authenticationFailed :: String -> IO (Response BodyReader)+authenticationFailed err = do+  Log.error err+  mkHtmlResponse internalServerError500 [qc|+<!DOCTYPE html>+<html lang="en">+  <head>+    <meta charset="utf-8">+    <title>Authentication Failed</title>+  </head>+  <body>+  <h1>Authentication Failed</h1>+    <p>Authentication Failed for an unknown reason.</p>+    <p><a href="/">Try again!</a></p>+  </body>+</html>+|]++accessDenied :: String -> IO (Response BodyReader)+accessDenied email = mkHtmlResponse forbidden403 [qc|+<!DOCTYPE html>+<html lang="en">+  <head>+    <meta charset="utf-8">+    <title>Access Denied</title>+  </head>+  <body>+  <h1>Access Denied</h1>+    <p>You are currently logged in as <strong>{email}</strong>.</p>+    <p><a href="/sproxy/logout">logout</a></p>+  </body>+</html>+|]++badRequest :: IO (Response BodyReader)+badRequest = mkTextResponse badRequest400 "400 Bad Request"++mkResponse :: Status -> [Header] -> ByteString -> IO (Response BodyReader)+mkResponse status headers_ body = Response status headers <$> fromByteString body+  where+    headers = ("Content-Length", B.pack . show . B.length $ body) : headers_++mkTextResponse :: Status -> ByteString -> IO (Response BodyReader)+mkTextResponse status = mkResponse status [("Content-Type", "text/plain")]++mkHtmlResponse :: Status -> ByteString -> IO (Response BodyReader)+mkHtmlResponse status = mkResponse status [("Content-Type", "text/html")]
+ src/Logging.hs view
@@ -0,0 +1,6 @@+module Logging (setup) where++import           System.Logging.LogSink.Config++setup :: LogLevel -> LogTarget -> IO ()+setup level target = setupLogging [SinkConfig level "{level} {thread-id}: {message}" target]
+ src/Main.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE QuasiQuotes #-}+module Main+(+  main+) where++import Data.Maybe (fromJust)+import Data.Version (showVersion)+import Paths_sproxy (version) -- from cabal+import System.Environment (getArgs)+import Text.RawString.QQ (r)+import qualified System.Console.Docopt.NoTH as O++import Authorize (withDatabaseAuthorizeAction)+import ConfigFile (withConfigFile, ConfigFile(..))+import Proxy (run)++usage :: String+usage =  "SProxy " ++ showVersion version +++  " HTTP proxy for authenticating users via Google OAuth2" ++ [r|++Usage:+  sproxy [options]++Options:+  -c, --config=FILE        Configuration file [default: config/sproxy.yml]+  -h, --help               Show this message++|]++main :: IO ()+main = do+  doco <- O.parseUsageOrExit usage+  args <- O.parseArgsOrExit doco =<< getArgs+  if args `O.isPresent` O.longOption "help"+  then putStrLn $ O.usage doco+  else do+    let+      configFile = fromJust . O.getArg args $ O.longOption "config"++    withConfigFile configFile $ \config ->+      withDatabaseAuthorizeAction (cfDatabase config) (run config)+
+ src/Proxy.hs view
@@ -0,0 +1,249 @@+{-# LANGUAGE OverloadedStrings, ScopedTypeVariables #-}+module Proxy (+  run+) where++import Control.Monad hiding (forM_)+import Data.Foldable (forM_)+import Control.Concurrent+import Control.Exception+import System.IO.Error+import GHC.IO.Exception+import Data.Monoid+import qualified Data.ByteString.Char8 as B8+import qualified Data.ByteString.Lazy as BL+import Data.Default (def)+import Data.List (intercalate)+import Data.Maybe+import Data.Map as Map (fromListWith, toList, insert, delete)+import Data.String.Conversions (cs)+import qualified Data.X509 as X509+import Network (PortID(..), listenOn, sClose, connectTo)+import Network.Socket (Socket, SockAddr, accept, close)+import qualified Network.Socket.ByteString as Socket+import Network.HTTP.Types+import qualified Network.TLS as TLS+import qualified Network.TLS.Extra as TLS+import System.IO+import System.Entropy (getEntropy)+import qualified Data.ByteString.Base64 as Base64+import Network.HTTP.Toolkit++import qualified System.Logging.Facade as Log++import Type+import Util+import Logging+import Authenticate+import Cookies+import Authorize+import ConfigFile+import HTTP++data Config = Config {+  configTLSCredential :: TLS.Credential+, configBackendAddress :: String+, configBackendPortID :: PortID+} deriving (Eq, Show)++-- | Reads the configuration file and the ssl certificate files and starts+-- the server+run :: ConfigFile -> AuthorizeAction -> IO ()+run cf authorize = do+  Logging.setup (cfLogLevel cf) (cfLogTarget cf)+  clientSecret <- strip <$> readFile (cfClientSecretFile cf)+  authTokenKey <- B8.unpack . Base64.encode <$> getEntropy 32+  credential <- either error reverseCerts <$> TLS.credentialLoadX509 (cfSslCerts cf) (cfSslKey cf)++  let authConfig = AuthConfig {+          authConfigCookieDomain = cfCookieDomain cf+        , authConfigCookieName = cfCookieName cf+        , authConfigClientID = cfClientID cf+        , authConfigClientSecret = clientSecret+        , authConfigAuthTokenKey = authTokenKey+        }+      config = Config {+          configTLSCredential = credential+        , configBackendAddress = cfBackendAddress cf+        , configBackendPortID =+            case cfBackendSocket cf of+              Just path -> UnixSocket path+              _ -> PortNumber . fromIntegral $ cfBackendPort cf+        }++  case configBackendPortID config of+    UnixSocket path -> hPutStrLn stderr $ "Forwarding to UNIX socket " ++ path+    PortNumber p -> hPutStrLn stderr $ "Forwarding to "+                       ++ configBackendAddress config ++ ":"+                       ++ show p+    _ -> return () -- XXX can't happen++  mvar <- newEmptyMVar++  -- Immediately fork a new thread for accepting connections since+  -- the main thread is special and expensive to communicate with.+  void . forkIO $ (runProxy (PortNumber . fromIntegral $ cfListen cf)+                            config authConfig authorize `catch` logException)+                 `finally` putMVar mvar ()++  -- Listen on port 80 just to redirect everything to HTTPS.+  let listen80 = fromMaybe (443 == cfListen cf) (cfRedirectHttpToHttps cf)++  when listen80 $+    void . forkIO $ listen (PortNumber 80) redirectToHttps `catch` logException++  takeMVar mvar+  where+    -- Usually combined certs are in server, intermediate order,+    -- but the tls library expects them in the opposite order.+    reverseCerts (X509.CertificateChain certs, key) = (X509.CertificateChain $ reverse certs, key)++runProxy :: PortID -> Config -> AuthConfig -> AuthorizeAction -> IO ()+runProxy port config authConfig authorize = listen port (serve config authConfig authorize)++-- | Redirects requests to https.+redirectToHttps :: SockAddr -> Socket -> IO ()+redirectToHttps _ sock = do+  conn <- makeInputStream (Socket.recv sock 4096)+  request <- readRequest True conn++  case baseUri (requestHeaders request) of+    Just uri -> do+      let location = uri <> requestPath request+      Log.debug ("Redirecting HTTP request to " ++ show location)+      simpleResponse send seeOther303 [("Location", location)] ""+    Nothing -> hostHeaderMissing request >>= sendResponse send+  where+    send = Socket.sendAll sock++-- | Actual server:+-- - ssl handshake+-- - google authentication+-- - our authorization+-- - redirecting requests to localhost:8080+serve :: Config -> AuthConfig -> AuthorizeAction -> SockAddr -> Socket -> IO ()+serve config authConfig authorize addr sock = do+  -- TODO: Work in the intermediate certificates.+  let params = def { TLS.serverShared = def { TLS.sharedCredentials = TLS.Credentials [configTLSCredential config] }+                   , TLS.serverSupported = def { TLS.supportedVersions = [TLS.TLS10, TLS.TLS11, TLS.TLS12]+                                               , TLS.supportedCiphers = TLS.ciphersuite_all } }+  ctx <- TLS.contextNew sock params+  TLS.handshake ctx+  conn <- makeInputStream (TLS.recvData ctx)+  serve_ (TLS.sendData ctx . BL.fromStrict) conn+  TLS.bye ctx+  where+    serve_ :: SendData -> InputStream -> IO ()+    serve_ send conn = go+      where+        go :: IO ()+        go = do+          continue <- handleOne+          when continue go++        handleOne :: IO Bool+        handleOne = do+          request@(Request _ path headers _) <- readRequest True conn+          mResponse <- case baseUri (requestHeaders request) of+            Nothing -> Just <$> hostHeaderMissing request+            Just uri -> do+              let (segments, query) = (decodePath . extractPath) path+              let redirectPath = fromMaybe "/" $ join $ lookup "state" query+              case segments of+                ["sproxy", "oauth2callback"] ->+                  case join $ lookup "code" query of+                    Nothing -> Just <$> badRequest+                    Just code -> Just <$> authenticate authConfig uri redirectPath code+                ["sproxy", "logout"] ->+                  Just <$> logout authConfig (uri <> redirectPath)+                -- sproxy sites are private by design. It doesn't make sense to index the authentication page.+                ["robots.txt"] -> Just <$> mkTextResponse ok200 "User-agent: *\nDisallow: /"+                _ -> -- Check for an auth cookie.+                  case removeCookie (authConfigCookieName authConfig) (parseCookies headers) of+                    Nothing -> Just <$> redirectForAuth authConfig path uri+                    Just (authCookie, cookies) -> do+                      auth <- validAuth authConfig authCookie+                      case auth of+                        Nothing -> Just <$> redirectForAuth authConfig path uri+                        Just token ->+                          forwardRequest config send authorize cookies addr request token+          forM_ mResponse (sendResponse send)+          return ((not . isConnectionClose) headers)++-- Check our access control list for this user's request and forward it to the backend if allowed.+forwardRequest :: Config -> SendData -> AuthorizeAction -> [(Name, Cookies.Value)] -> SockAddr -> Request BodyReader -> AuthToken -> IO (Maybe (Response BodyReader))+forwardRequest config send authorize cookies addr request@(Request method path headers _) token = do+    groups <- authorize (authEmail token) (maybe (error "No Host") cs $ lookup "Host" headers) path method+    ip <- formatSockAddr addr+    case groups of+        [] -> Just <$> accessDenied (authEmail token)+        _ -> do+            -- TODO: Reuse connections to the backend server.+            let downStreamHeaders =+                    toList $+                    insert "From" (cs $ authEmail token) $+                    insert "X-Groups" (cs $ intercalate "," groups) $+                    insert "X-Given-Name" (cs $ fst $ authName token) $+                    insert "X-Family-Name" (cs $ snd $ authName token) $+                    insert "X-Forwarded-Proto" "https" $+                    addForwardedForHeader ip $+                    insert "Connection" "close" $+                    setCookies $ fromListWith (\a b -> B8.concat [a, ",", b]) headers+            Log.debug $ show downStreamHeaders+            bracket (connectTo host portID) hClose $ \h -> do+              sendRequest (B8.hPutStr h) request{requestHeaders = downStreamHeaders}+              conn <- inputStreamFromHandle h+              response <- readResponse True method conn+              sendResponse send response{responseHeaders = removeConnectionHeader (responseHeaders response)}+              return Nothing+  where+    host = configBackendAddress config+    portID = configBackendPortID config+    setCookies = case cookies of+      [] -> delete hCookie+      _  -> insert hCookie (formatCookies cookies)++listen :: PortID -> (SockAddr -> Socket -> IO ()) -> IO ()+listen port action =+  bracket+  ( do+    sock <- listenOn port+    hPutStrLn stderr $ "Listening on " ++ show port+    return sock )+  sClose $+  \serverSock -> forever $ do+  (sock, addr) <- accept serverSock+  forkIO $ (action addr sock `finally` close sock) `catches` handlers+  where+    handlers = [ Handler ioH+               , Handler toolkitH+               , Handler tlsH+               , Handler logException ]++    ioH :: IOException -> IO ()+    ioH e+      | ResourceVanished == ioeGetErrorType e = clientClosedConection e+      | otherwise = logException' e++    toolkitH :: ToolkitError -> IO ()+    toolkitH e@UnexpectedEndOfInput = clientClosedConection e+    toolkitH e = logException' e++    tlsH :: TLS.TLSException -> IO ()+    tlsH e@(TLS.HandshakeFailed TLS.Error_EOF) = clientClosedConection e+    tlsH e@(TLS.HandshakeFailed (TLS.Error_Protocol (_, _, _))) = clientError e+    tlsH e@(TLS.HandshakeFailed (TLS.Error_Packet_Parsing _)) = clientError e+    tlsH e = logException' e++    logException' :: Exception e => e -> IO ()+    logException' = logException . toException++    clientClosedConection :: Exception e => e -> IO ()+    clientClosedConection e = Log.debug ("client closed connection (" ++ displayException e ++ ")")++    clientError :: Exception e => e -> IO ()+    clientError e = Log.debug ("client error (" ++ displayException e ++ ")")++logException :: SomeException -> IO ()+logException = Log.error . displayException+
+ src/Type.hs view
@@ -0,0 +1,5 @@+module Type where++import           Data.ByteString (ByteString)++type SendData = ByteString -> IO ()
+ src/Util.hs view
@@ -0,0 +1,37 @@+{-# LANGUAGE OverloadedStrings #-}+module Util where++import           Data.Char+import           Data.String+import           Data.Monoid+import           Data.Map (Map)+import qualified Data.Map as Map+import           Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as B+import           Network.HTTP.Types+import           Network.Socket++formatSockAddr :: SockAddr -> IO HostName+formatSockAddr addr = (fst <$> getNameInfo [NI_NUMERICHOST] True False addr) >>= maybe err return+  where+    err = error "Util.formatSockAddr: impossible internal error"++addForwardedForHeader :: HostName -> Map HeaderName ByteString -> Map HeaderName ByteString+addForwardedForHeader ip = Map.insertWith combine "X-Forwarded-For" (fromString ip)+  where+    combine new old = mconcat [old, ", ", new]++removeConnectionHeader :: [Header] -> [Header]+removeConnectionHeader = filter ((/= hConnection) . fst)++strip :: String -> String+strip = reverse . dropWhile isSpace . reverse . dropWhile isSpace++baseUri :: [Header] -> Maybe ByteString+baseUri headers = ("https://" <>) <$> lookup "Host" headers++isConnectionClose :: [Header] -> Bool+isConnectionClose = any p+  where+    p (k, v) = k == hConnection && B.map toLower (stripBS v) == "close"+    stripBS = B.reverse . B.dropWhile isSpace . B.reverse . B.dropWhile isSpace