{-# OPTIONS_GHC -fno-warn-orphans #-} {-| Module : Servant.Server.Auth.Token Description : Implementation of token authorisation API Copyright : (c) Anton Gushcha, 2016 License : MIT Maintainer : ncrashed@gmail.com Stability : experimental Portability : Portable The module is server side implementation of "Servant.API.Auth.Token" API and intended to be used as drop in module for user servers or as external micro service. To use the server as constituent part, you need to provide customised 'AuthConfig' for 'authServer' function and implement 'AuthMonad' instance for your handler monad. @ import Servant.Server.Auth.Token as Auth -- | Example of user side configuration data Config = Config { -- | Authorisation specific configuration authConfig :: AuthConfig -- other fields -- ... } -- | Example of user side handler monad newtype App a = App { runApp :: ReaderT Config (ExceptT ServantErr IO) a } deriving ( Functor, Applicative, Monad, MonadReader Config, MonadError ServantErr, MonadIO) -- | Now you can use authorisation API in your handler instance AuthMonad App where getAuthConfig = asks authConfig liftAuthAction = App . lift -- | Include auth 'migrateAll' function into your migration code doMigrations :: SqlPersistT IO () doMigrations = runMigrationUnsafe $ do migrateAll -- other user migrations Auth.migrateAll -- creation of authorisation entities -- optional creation of default admin if db is empty ensureAdmin 17 "admin" "123456" "admin@localhost" @ Now you can use 'guardAuthToken' to check authorisation headers in endpoints of your server: @ -- | Read a single customer from DB customerGet :: CustomerId -- ^ Customer unique id -> MToken '["customer-read"] -- ^ Required permissions for auth token -> App Customer -- ^ Customer data customerGet i token = do guardAuthToken token runDB404 "customer" $ getCustomer i @ -} module Servant.Server.Auth.Token( -- * Implementation authServer -- * Server API , migrateAll , AuthMonad(..) -- * Helpers , guardAuthToken , ensureAdmin , authUserByToken -- * API methods , authSignin , authTouch , authToken , authSignout , authSignup , authUsersInfo , authUserInfo , authUserPatch , authUserPut , authUserDelete , authRestore , authGroupGet , authGroupPost , authGroupPut , authGroupPatch , authGroupDelete , authGroupList ) where import Control.Monad import Control.Monad.Except import Control.Monad.Reader import Crypto.PasswordStore import Data.Aeson.WithField import Data.Maybe import Data.Monoid import Data.Text.Encoding import Data.Time.Clock import Data.UUID import Data.UUID.V4 import Database.Persist.Postgresql import Servant import Servant.API.Auth.Token import Servant.API.Auth.Token.Pagination import Servant.Server.Auth.Token.Common import Servant.Server.Auth.Token.Config import Servant.Server.Auth.Token.Model import Servant.Server.Auth.Token.Monad import Servant.Server.Auth.Token.Pagination import Servant.Server.Auth.Token.Restore import qualified Data.ByteString.Lazy as BS -- | This function converts our 'AuthHandler' monad into the @ExceptT ServantErr -- IO@ monad that Servant's 'enter' function needs in order to run the -- application. The ':~>' type is a natural transformation, or, in -- non-category theory terms, a function that converts two type -- constructors without looking at the values in the types. convertAuthHandler :: AuthConfig -> AuthHandler :~> ExceptT ServantErr IO convertAuthHandler cfg = Nat (flip runReaderT cfg . runAuthHandler) -- | The interface your application should implement to be able to use -- token aurhorisation API. class Monad m => AuthMonad m where getAuthConfig :: m AuthConfig liftAuthAction :: ExceptT ServantErr IO a -> m a instance AuthMonad AuthHandler where getAuthConfig = getConfig liftAuthAction = AuthHandler . lift -- | Helper to run handler in 'AuthMonad' context runAuth :: AuthMonad m => AuthHandler a -> m a runAuth m = do cfg <- getAuthConfig let Nat conv = convertAuthHandler cfg liftAuthAction $ conv m -- | Implementation of AuthAPI authServer :: AuthConfig -> Server AuthAPI authServer cfg = enter (convertAuthHandler cfg) ( authSignin :<|> authTouch :<|> authToken :<|> authSignout :<|> authSignup :<|> authUsersInfo :<|> authUserInfo :<|> authUserPatch :<|> authUserPut :<|> authUserDelete :<|> authRestore :<|> authGroupGet :<|> authGroupPost :<|> authGroupPut :<|> authGroupPatch :<|> authGroupDelete :<|> authGroupList) -- | Implementation of "signin" method authSignin :: AuthMonad m => Maybe Login -- ^ Login query parameter -> Maybe Password -- ^ Password query parameter -> Maybe Seconds -- ^ Expire query parameter, how many seconds the token is valid -> m (OnlyField "token" SimpleToken) -- ^ If everthing is OK, return token authSignin mlogin mpass mexpire = runAuth $ do login <- require "login" mlogin pass <- require "pass" mpass Entity uid UserImpl{..} <- guardLogin login pass expire <- calcExpire mexpire mt <- getExistingToken uid -- check whether there is already existing token OnlyField <$> case mt of Nothing -> createToken uid expire -- create new token Just t -> touchToken t expire -- prolong token expiration time where guardLogin login pass = do -- check login and password, return passed user muser <- runDB $ selectFirst [UserImplLogin ==. login] [] let err = throw401 "Cannot find user with given combination of login and pass" case muser of Nothing -> err Just user@(Entity _ UserImpl{..}) -> if passToByteString pass `verifyPassword` passToByteString userImplPassword then return user else err getExistingToken uid = do -- return active token for specified user id t <- liftIO getCurrentTime runDB $ selectFirst [AuthTokenUser ==. uid, AuthTokenExpire >. t] [] createToken uid expire = do -- generate and save fresh token token <- toText <$> liftIO nextRandom _ <- runDB $ insert AuthToken { authTokenValue = token , authTokenUser = uid , authTokenExpire = expire } return token -- | Calculate expiration timestamp for token calcExpire :: Maybe Seconds -> AuthHandler UTCTime calcExpire mexpire = do t <- liftIO getCurrentTime AuthConfig{..} <- getConfig let requestedExpire = maybe defaultExpire fromIntegral mexpire let boundedExpire = maybe requestedExpire (min requestedExpire) maximumExpire return $ boundedExpire `addUTCTime` t -- prolong token with new timestamp touchToken :: Entity AuthToken -> UTCTime -> AuthHandler SimpleToken touchToken (Entity tid tok) expire = do runDB $ replace tid tok { authTokenExpire = expire } return $ authTokenValue tok -- | Implementation of "touch" method authTouch :: AuthMonad m => Maybe Seconds -- ^ Expire query parameter, how many seconds the token should be valid by now. 'Nothing' means default value defined in server config. -> MToken '[] -- ^ Authorisation header with token -> m () authTouch mexpire token = runAuth $ do Entity i mt <- guardAuthToken' (fmap unToken token) [] expire <- calcExpire mexpire runDB $ replace i mt { authTokenExpire = expire } -- | Implementation of "token" method, return -- info about user binded to the token authToken :: AuthMonad m => MToken '[] -- ^ Authorisation header with token -> m RespUserInfo authToken token = runAuth $ do i <- authUserByToken token runDB404 "user" . readUserInfo . fromKey $ i -- | Getting user id by token authUserByToken :: AuthMonad m => MToken '[] -> m UserImplId authUserByToken token = runAuth $ do Entity _ mt <- guardAuthToken' (fmap unToken token) [] return $ authTokenUser mt -- | Implementation of "signout" method authSignout :: AuthMonad m => Maybe (Token '[]) -- ^ Authorisation header with token -> m () authSignout token = runAuth $ do Entity i mt <- guardAuthToken' (fmap unToken token) [] expire <- liftIO getCurrentTime runDB $ replace i mt { authTokenExpire = expire } -- | Checks given password and if it is invalid in terms of config -- password validator, throws 400 error. guardPassword :: Password -> AuthHandler () guardPassword p = do AuthConfig{..} <- getConfig whenJust (passwordValidator p) $ throw400 . BS.fromStrict . encodeUtf8 -- | Implementation of "signup" method authSignup :: AuthMonad m => ReqRegister -- ^ Registration info -> MToken '["auth-register"] -- ^ Authorisation header with token -> m (OnlyField "user" UserId) authSignup ReqRegister{..} token = runAuth $ do guardAuthToken token guardUserInfo guardPassword reqRegPassword strength <- getsConfig passwordsStrength i <- runDB $ do i <- createUser strength reqRegLogin reqRegPassword reqRegEmail reqRegPermissions whenJust reqRegGroups $ setUserGroups i return i return $ OnlyField . fromKey $ i where guardUserInfo = do c <- runDB $ count [UserImplLogin ==. reqRegLogin] when (c > 0) $ throw400 "User with specified id is already registered" -- | Implementation of get "users" method authUsersInfo :: AuthMonad m => Maybe Page -- ^ Page num parameter -> Maybe PageSize -- ^ Page size parameter -> MToken '["auth-info"] -- ^ Authorisation header with token -> m RespUsersInfo authUsersInfo mp msize token = runAuth $ do guardAuthToken token pagination mp msize $ \page size -> do (users, total) <- runDB $ (,) <$> (do users <- selectList [] [Asc UserImplId, OffsetBy (fromIntegral $ page * size), LimitTo (fromIntegral size)] perms <- mapM (getUserPermissions . entityKey) users groups <- mapM (getUserGroups . entityKey) users return $ zip3 users perms groups) <*> count ([] :: [Filter UserImpl]) return RespUsersInfo { respUsersItems = (\(user, perms, groups) -> userToUserInfo user perms groups) <$> users , respUsersPages = ceiling $ (fromIntegral total :: Double) / fromIntegral size } -- | Implementation of get "user" method authUserInfo :: AuthMonad m => UserId -- ^ User id -> MToken '["auth-info"] -- ^ Authorisation header with token -> m RespUserInfo authUserInfo uid' token = runAuth $ do guardAuthToken token runDB404 "user" $ readUserInfo uid' -- | Implementation of patch "user" method authUserPatch :: AuthMonad m => UserId -- ^ User id -> PatchUser -- ^ JSON with fields for patching -> MToken '["auth-update"] -- ^ Authorisation header with token -> m () authUserPatch uid' body token = runAuth $ do guardAuthToken token whenJust (patchUserPassword body) guardPassword let uid = toSqlKey . fromIntegral $ uid' user <- guardUser uid strength <- getsConfig passwordsStrength Entity _ user' <- runDB $ patchUser strength body $ Entity uid user runDB $ replace uid user' -- | Implementation of put "user" method authUserPut :: AuthMonad m => UserId -- ^ User id -> ReqRegister -- ^ New user -> MToken '["auth-update"] -- ^ Authorisation header with token -> m () authUserPut uid' ReqRegister{..} token = runAuth $ do guardAuthToken token guardPassword reqRegPassword let uid = toSqlKey . fromIntegral $ uid' let user = UserImpl { userImplLogin = reqRegLogin , userImplPassword = "" , userImplEmail = reqRegEmail } user' <- setUserPassword reqRegPassword user runDB $ do replace uid user' setUserPermissions uid reqRegPermissions whenJust reqRegGroups $ setUserGroups uid -- | Implementation of patch "user" method authUserDelete :: AuthMonad m => UserId -- ^ User id -> MToken '["auth-delete"] -- ^ Authorisation header with token -> m () authUserDelete uid' token = runAuth $ do guardAuthToken token runDB $ deleteCascade (toKey uid' :: UserImplId) -- Generate new password for user. There is two phases, first, the method -- is called without 'code' parameter. The system sends email with a restore code -- to email. After that a call of the method with the code is needed to -- change password. Need configured SMTP server. authRestore :: AuthMonad m => UserId -- ^ User id -> Maybe RestoreCode -> Maybe Password -> m () authRestore uid' mcode mpass = runAuth $ do let uid = toKey uid' user <- guardUser uid case mcode of Nothing -> do dt <- getsConfig restoreExpire t <- liftIO getCurrentTime AuthConfig{..} <- getConfig rc <- runDB $ getRestoreCode restoreCodeGenerator uid $ addUTCTime dt t uinfo <- runDB404 "user" $ readUserInfo uid' sendRestoreCode uinfo rc Just code -> do pass <- require "password" mpass guardPassword pass guardRestoreCode uid code user' <- setUserPassword pass user runDB $ replace uid user' -- | Getting user by id, throw 404 response if not found guardUser :: UserImplId -> AuthHandler UserImpl guardUser uid = do muser <- runDB $ get uid case muser of Nothing -> throw404 "User not found" Just user -> return user -- | If the token is missing or the user of the token -- doesn't have needed permissions, throw 401 response guardAuthToken :: forall perms m . (PermsList perms, AuthMonad m) => MToken perms -> m () guardAuthToken mt = runAuth $ void $ guardAuthToken' (fmap unToken mt) $ unliftPerms (Proxy :: Proxy perms) -- | Same as `guardAuthToken` but returns record about the token guardAuthToken' :: Maybe SimpleToken -> [Permission] -> AuthHandler (Entity AuthToken) guardAuthToken' Nothing _ = throw401 "Token required" guardAuthToken' (Just token) perms = do t <- liftIO getCurrentTime mt <- runDB $ selectFirst [AuthTokenValue ==. token] [] case mt of Nothing -> throw401 "Token is not valid" Just et@(Entity _ AuthToken{..}) -> do when (t > authTokenExpire) $ throwError $ err401 { errBody = "Token expired" } mu <- runDB $ get authTokenUser case mu of Nothing -> throw500 "User of the token doesn't exist" Just UserImpl{..} -> do isAdmin <- runDB $ hasPerm authTokenUser adminPerm hasAllPerms <- runDB $ hasPerms authTokenUser perms unless (isAdmin || hasAllPerms) $ throw401 $ "User doesn't have all required permissions: " <> showb perms return et -- | Rehash password for user setUserPassword :: Password -> UserImpl -> AuthHandler UserImpl setUserPassword pass user = do strength <- getsConfig passwordsStrength setUserPassword' strength pass user -- | Getting info about user group, requires 'authInfoPerm' for token authGroupGet :: AuthMonad m => UserGroupId -> MToken '["auth-info"] -- ^ Authorisation header with token -> m UserGroup authGroupGet i token = runAuth $ do guardAuthToken token runDB404 "user group" $ readUserGroup i -- | Inserting new user group, requires 'authUpdatePerm' for token authGroupPost :: AuthMonad m => UserGroup -> MToken '["auth-update"] -- ^ Authorisation header with token -> m (OnlyId UserGroupId) authGroupPost ug token = runAuth $ do guardAuthToken token runDB $ OnlyField <$> insertUserGroup ug -- | Replace info about given user group, requires 'authUpdatePerm' for token authGroupPut :: AuthMonad m => UserGroupId -> UserGroup -> MToken '["auth-update"] -- ^ Authorisation header with token -> m () authGroupPut i ug token = runAuth $ do guardAuthToken token runDB $ updateUserGroup i ug -- | Patch info about given user group, requires 'authUpdatePerm' for token authGroupPatch :: AuthMonad m => UserGroupId -> PatchUserGroup -> MToken '["auth-update"] -- ^ Authorisation header with token -> m () authGroupPatch i up token = runAuth $ do guardAuthToken token runDB $ patchUserGroup i up -- | Delete all info about given user group, requires 'authDeletePerm' for token authGroupDelete :: AuthMonad m => UserGroupId -> MToken '["auth-delete"] -- ^ Authorisation header with token -> m () authGroupDelete i token = runAuth $ do guardAuthToken token runDB $ deleteUserGroup i -- | Get list of user groups, requires 'authInfoPerm' for token authGroupList :: AuthMonad m => Maybe Page -> Maybe PageSize -> MToken '["auth-info"] -- ^ Authorisation header with token -> m (PagedList UserGroupId UserGroup) authGroupList mp msize token = runAuth $ do guardAuthToken token pagination mp msize $ \page size -> do (groups, total) <- runDB $ (,) <$> (do is <- selectKeysList [] [Asc AuthUserGroupId, OffsetBy (fromIntegral $ page * size), LimitTo (fromIntegral size)] forM is $ (\i -> fmap (WithField i) <$> readUserGroup i) . fromKey) <*> count ([] :: [Filter AuthUserGroup]) return PagedList { pagedListItems = catMaybes groups , pagedListPages = ceiling $ (fromIntegral total :: Double) / fromIntegral size }