module Web.Controller.Api.V2.Auth where import IHP.Prelude import IHP.ControllerPrelude import Web.Types import Generated.Types import Data.Aeson (object, (.=)) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified "cryptohash-sha256" Crypto.Hash.SHA256 as SHA256 import qualified Data.ByteString.Base16 as Base16 import Network.Wai (requestHeaders, responseLBS) import IHP.Controller.Response (ResponseException (..)) -- | Extract Bearer token from Authorization header and validate it -- against the api_keys table. Returns the ApiConsumer on success, -- or halts with 401 JSON on failure. requireApiConsumer :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ApiConsumer requireApiConsumer = do let authHeader = lookup "Authorization" (requestHeaders ?request) let mToken = authHeader >>= \h -> let t = cs h :: Text in if "Bearer " `T.isPrefixOf` t then Just (T.drop 7 t) else Nothing case mToken of Nothing -> unauthorized401 Just token -> do let tokenHash = hashApiKey token now <- getCurrentTime mKey <- query @ApiKey |> filterWhere (#keyHash, tokenHash) |> fetchOneOrNothing case mKey of Nothing -> unauthorized401 Just apiKey -> do when (isJust apiKey.revokedAt) unauthorized401 when (maybe False (< now) apiKey.expiresAt) do respondWithStatus 401 $ object [ "error" .= ("Token expired" :: Text) , "code" .= ("token_expired" :: Text) ] -- Update last_used_at (fire-and-forget; do not block on failure) apiKey |> set #lastUsedAt (Just now) |> updateRecord fetch apiKey.apiConsumerId >>= \consumer -> do unless consumer.isActive unauthorized401 pure consumer unauthorized401 :: (?respond :: Respond) => IO a unauthorized401 = respondWithStatus 401 $ object [ "error" .= ("Unauthorized" :: Text) , "code" .= ("invalid_api_key" :: Text) ] respondWithStatus :: (?respond :: Respond) => Int -> Value -> IO a respondWithStatus status body = throwIO $ ResponseException $ responseLBS (toEnum status) [("Content-Type", "application/json")] (encode body) -- | SHA-256 hex hash of the key (same as stored in key_hash column) hashApiKey :: Text -> Text hashApiKey key = let bytes = TE.encodeUtf8 key digest = SHA256.hash bytes in TE.decodeUtf8 (Base16.encode digest) -- | Standard paginated response envelope paginatedResponse :: ToJSON a => [a] -> Int -> Int -> Int -> Value paginatedResponse items page perPage total = object [ "data" .= items , "meta" .= object [ "page" .= page , "per_page" .= perPage , "total" .= total ] ] -- | Parse page / per_page query params with sensible defaults getPageParams :: (?context :: ControllerContext, ?request :: Request) => IO (Int, Int) getPageParams = do let page = fromMaybe 1 (paramOrNothing @Int "page") perPage = fromMaybe 50 (paramOrNothing @Int "per_page") let perPage' = min 200 (max 1 perPage) let page' = max 1 page pure (page', perPage')