packages feed

pixiv-0.1.0: src/Web/Pixiv/Auth.hs

{-# LANGUAGE TemplateHaskell #-}

-- | Copyright: (c) 2021 The closed eye of love
-- SPDX-License-Identifier: BSD-3-Clause
-- Maintainer: Poscat <poscat@mail.poscat.moe>, berberman <berberman@yandex.com>
-- Stability: alpha
-- Portability: portable
-- Authentication pixiv API. Users should not use logics in this module directly,
-- since "Web.Pixiv.Types.PixivT" takes over token management, providing user friendly operations.
module Web.Pixiv.Auth
  ( Token (..),
    Credential (..),
    OAuth2Token (..),
    OAuth2Error (..),
    OAuth2Result (..),
    Errors (..),
    auth,
    auth',
  )
where

import Control.Applicative ((<|>))
import Control.Exception.Base
import Crypto.Hash.MD5 (hash)
import Data.Aeson
import Data.Aeson.TH
import Data.ByteString (ByteString)
import qualified Data.ByteString.Base16 as B16
import qualified Data.ByteString.Char8 as C
import Data.String (IsString (..))
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time (defaultTimeLocale, formatTime, getCurrentTime)
import Network.HTTP.Client
import Network.HTTP.Client.MultipartFormData (PartM, formDataBody, partBS)
import Web.Pixiv.TH

clientId :: ByteString
clientId = "MOBrBDS8blbauoSck0ZfDbtuzpyT"

clientSecret :: ByteString
clientSecret = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj"

hashSecret :: ByteString
hashSecret = "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c"

-- | A wrapped 'Text' represents a token.
newtype Token = Token {unToken :: Text}
  deriving stock (Show, Eq, Read)

deriveJSON defaultOptions {unwrapUnaryRecords = True} ''Token

instance IsString Token where
  fromString = Token . T.pack

-- | Authentication credentials for pixiv API.
--
-- Normally, users are supposed to create value of this data type using 'Password' constructor,
-- then pass it to 'Web.Pixiv.Types.PixivT.runPixivT'.
data Credential
  = Password
      { username :: ByteString,
        password :: ByteString
      }
  | RefreshToken
      { cr_refreshToken :: Token
      }
  deriving stock (Show, Eq)

mkAuthParts :: Applicative m => Credential -> [PartM m]
mkAuthParts Password {..} =
  [ partBS "grant_type" "password",
    partBS "username" username,
    partBS "password" password
  ]
mkAuthParts RefreshToken {..} =
  [ partBS "grant_type" "refresh_token",
    partBS "refresh_token" (encodeUtf8 . unToken $ cr_refreshToken)
  ]

-- | Successful result.
data OAuth2Token = OAuth2Token
  { oa_accessToken :: Token,
    oa_expiresIn :: Int,
    oa_refreshToken :: Token
  }
  deriving stock (Show, Eq, Read)

derivePixivJSON "oa_" ''OAuth2Token

-- | Authentication failure reasions.
data Errors
  = InvalidRequest
  | InvalidClient
  | InvalidGrant
  | UnauthorizedClient
  | UnsupportedGrantType
  | InvalidScope
  deriving stock (Show, Eq, Ord, Enum, Read)

deriveEnumJSON' ''Errors

-- | Failed result.
data OAuth2Error = OAuth2Error
  { oa_error :: Errors,
    oa_message :: Text
  }
  deriving stock (Show, Eq, Read)
  deriving anyclass (Exception)

instance FromJSON OAuth2Error where
  parseJSON = withObject "oauth2 response" $ \o -> do
    oa_error <- o .: "error"
    errors <- o .: "errors"
    oa_message <- flip (withObject "errors") errors $ \o' -> do
      system <- o' .: "system"
      flip (withObject "system") system $ \o'' -> do
        o'' .: "message"
    pure OAuth2Error {..}

-- | Authentication result.
data OAuth2Result
  = AuthSuccess OAuth2Token
  | AuthFailure OAuth2Error
  deriving stock (Show, Eq)

instance FromJSON OAuth2Result where
  parseJSON v =
    AuthSuccess <$> parseJSON v <|> AuthFailure <$> parseJSON v

-- | Given a credential, performs a authentication request.
auth :: Manager -> Credential -> IO OAuth2Result
auth manager credential = do
  let authUrl = "https://oauth.secure.pixiv.net/auth/token"
  initReq <- parseRequest authUrl
  utcT <- getCurrentTime
  let clientTime = C.pack $ formatTime defaultTimeLocale "%Y-%m-%dT%H:%I:%S+00:00" utcT
      clientHash = B16.encode $ hash $ clientTime <> hashSecret
      headers =
        [ ("User-Agent", "PixivAndroidApp/5.0.64 (Android 6.0)"),
          ("X-Client-Time", clientTime),
          ("X-Client-Hash", clientHash)
        ]
      req = initReq {requestHeaders = headers}
      parts =
        [ partBS "client_id" clientId,
          partBS "client_secret" clientSecret,
          partBS "get_secure_url" "1"
        ]
          ++ mkAuthParts credential
  finalReq <- formDataBody parts req
  resp <- httpLbs finalReq manager
  let body = responseBody resp
  maybe (fail "impossible: unable to parse response") pure (decode body)

-- | Like 'auth', but immediately throws 'OAuth2Error' if auth failed.
auth' :: Manager -> Credential -> IO OAuth2Token
auth' manager credential =
  auth manager credential >>= \case
    AuthSuccess t -> pure t
    AuthFailure err -> throwIO err