Merge pull request #112 from AnonymusRaccoon/feat/oauth-signin

Signin / Signup / Authorizations Redirect with Services
This commit is contained in:
Clément Le Bihan
2022-03-06 17:03:33 +01:00
committed by GitHub
22 changed files with 635 additions and 224 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ $(deriveJSON defaultOptions ''About)
servicesDir :: [(FilePath, S.ByteString)]
servicesDir = $(embedDir "./services/")
-- servicesDir = undefined
--servicesDir = undefined
about :: SockAddr -> AppM About
about host = do
+53 -14
View File
@@ -18,14 +18,18 @@ import Servant (
Post,
ReqBody,
err401,
err400,
err403,
throwError,
type (:<|>) (..),
type (:>),
type (:>), Capture, QueryParam
)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Maybe (MaybeT(runMaybeT))
import Core.OIDC ( getOauthTokens )
import Data.Aeson (FromJSON, ToJSON)
import Db.User (User', UserDB (UserDB), password, toUser)
import Db.User (User', UserDB (UserDB, userDBId), password, toUser)
import GHC.Generics (Generic)
import Servant.API.Generic (ToServantApi, (:-))
import Servant.Auth.Server (CookieSettings, JWT, JWTSettings, SetCookie, ThrowAll (throwAll), acceptLogin, AuthResult (Authenticated), makeJWT)
@@ -34,10 +38,10 @@ import Servant.Server.Generic (AsServerT)
import Data.ByteString.Lazy.Char8 ( unpack )
import Api.OIDC (OauthAPI, oauth)
import App (AppM)
import Core.User (User, UserId (UserId))
import Core.User (User, UserId (UserId), Service)
import Data.Text (pack)
import Password (hashPassword'', toPassword, validatePassword')
import Repository (createUser, getUserByName')
import Repository (createUser, getUserByName', getUserByToken, updateTokens)
import Utils (UserAuth, AuthRes)
data LoginUser = LoginUser
@@ -57,15 +61,16 @@ newtype LoginResponse = LoginResponse
}
deriving (Eq, Show, Read, Generic)
instance ToJSON LoginResponse
instance FromJSON LoginResponse
instance ToJSON LoginUser
instance FromJSON LoginUser
instance ToJSON SignupUser
instance FromJSON SignupUser
instance ToJSON LoginResponse
instance FromJSON LoginResponse
type Protected =
"me" :> Get '[JSON] User
@@ -80,6 +85,14 @@ type Unprotected =
:<|> "signup"
:> ReqBody '[JSON] SignupUser
:> Post '[JSON] NoContent
:<|> Capture "service" Service :> "signin"
:> QueryParam "code" String
:> Post '[JSON] LoginResponse
:<|> Capture "service" Service :> "signup"
:> QueryParam "code" String
:> ReqBody '[JSON] SignupUser
:> Post '[JSON] LoginResponse
loginHandler ::
CookieSettings ->
@@ -95,11 +108,35 @@ loginHandler cs jwts (LoginUser username p) = do
case etoken of
Left e -> throwError err401
Right v -> return $ LoginResponse $ unpack v
{--mApplyCookies <- liftIO $ acceptLogin cs jwts (toUser usr)
case mApplyCookies of
Nothing -> throwError err401
Just applyCookies -> return $ applyCookies NoContent
--}else throwError err401
else throwError err401
loginOauthHandler :: JWTSettings -> Service -> Maybe String -> AppM LoginResponse
loginOauthHandler jwts _ Nothing = throwError err400
loginOauthHandler jwts service (Just code) = do
tokens <- liftIO $ runMaybeT $ getOauthTokens service code
case tokens of
Nothing -> throwError err403
Just t -> do
user <- getUserByToken t
etoken <- liftIO $ makeJWT (toUser user) jwts Nothing
case etoken of
Left e -> throwError err401
Right v -> return $ LoginResponse $ unpack v
signupOauthHandler :: JWTSettings -> Service -> Maybe String -> SignupUser -> AppM LoginResponse
signupOauthHandler jwts service (Just code) (SignupUser name p) = do
hashed <- hashPassword'' $ toPassword $ pack p
user <- createUser $ UserDB (UserId 1) (pack name) hashed (pack name) []
tokens <- liftIO $ runMaybeT $ getOauthTokens service code
case tokens of
Nothing -> throwError err403
Just t -> do
updateTokens (userDBId user) t
etoken <- liftIO $ makeJWT (toUser user) jwts Nothing
case etoken of
Left e -> throwError err401
Right v -> return $ LoginResponse $ unpack v
signupOauthHandler _ _ _ _ = throwError err400
signupHandler ::
SignupUser ->
@@ -111,8 +148,10 @@ signupHandler (SignupUser name p) = do
unprotected :: CookieSettings -> JWTSettings -> ServerT Unprotected AppM
unprotected cs jwts =
loginHandler cs jwts
:<|> signupHandler
loginHandler cs jwts
:<|> signupHandler
:<|> loginOauthHandler jwts
:<|> signupOauthHandler jwts
data AuthAPI mode = AuthAPI
{ protectedApi :: mode :- UserAuth :> Protected
+8 -6
View File
@@ -8,21 +8,23 @@ module Api.OIDC where
import App (AppM)
import Control.Monad.IO.Class (liftIO)
import Core.User (ExternalToken (ExternalToken, service), Service (Github, Spotify, Twitter, Google, Anilist, Discord), UserId (UserId), User (User))
import Data.Text (pack)
import Data.Text (pack, unpack)
import Core.OIDC ( getOauthTokens )
import Repository.User (updateTokens, getTokensByUserId, delTokens)
import Servant (Capture, Get, GetNoContent, JSON, NoContent (NoContent), QueryParam, ServerT, err400, throwError, type (:<|>) ((:<|>)), type (:>), err401, err403, ServerError (errHeaders), err302, Delete)
import Servant (Capture, Get, GetNoContent, JSON, NoContent (NoContent), QueryParam, ServerT, err400, throwError, type (:<|>) ((:<|>)), type (:>), err401, err403, ServerError (errHeaders), err302, Delete, Post)
import Servant.API.Generic (type (:-))
import Servant.Server.Generic (AsServerT)
import Utils (UserAuth, AuthRes)
import qualified Data.ByteString.Char8 as B8
import Servant.Auth.Server (AuthResult(Authenticated))
import Servant.Auth.Server (AuthResult(Authenticated), makeJWT)
import System.Environment.MrEnv (envAsString)
import Control.Monad.Trans.Maybe (MaybeT(runMaybeT))
import Db.User (toUser)
oauthHandler :: AuthRes -> Service -> Maybe String -> AppM NoContent
oauthHandler _ _ Nothing = throwError err400
oauthHandler (Authenticated (User uid _ _)) service (Just code) = do
tokens <- liftIO $ getOauthTokens service code
tokens <- liftIO $ runMaybeT $ getOauthTokens service code
case tokens of
Nothing -> throwError err403
Just t -> do
@@ -89,10 +91,10 @@ type OauthAPI = UserAuth :> Capture "service" Service :> QueryParam "code" Strin
:<|> Capture "service" Service :> "url" :> QueryParam "redirect_uri" String :> Get '[JSON] NoContent
:<|> UserAuth :> "services" :> Get '[JSON] [String]
:<|> "redirect" :> QueryParam "code" String :> QueryParam "state" String :> Get '[JSON] NoContent
oauth :: ServerT OauthAPI AppM
oauth = oauthHandler
:<|> oauthDelHandler
:<|> urlHandler
:<|> servicesHandler
:<|> redirectHandler
:<|> redirectHandler
+1 -1
View File
@@ -114,7 +114,7 @@ refreshHandler :: Service -> UserId -> Maybe String -> RefreshBody -> AppM NoCon
refreshHandler service uid (Just key) (RefreshBody at rt ex) = do
k <- liftIO $ envAsString "WORKER_API_KEY" ""
if k == key then do
updateTokens uid $ ExternalToken at rt ex service
updateTokens uid $ ExternalToken at rt ex service Nothing
return NoContent
else throwError err403
refreshHandler _ _ _ _ = throwError err403
+166 -64
View File
@@ -6,13 +6,17 @@ import qualified Data.ByteString.Char8 as B8
import qualified Data.HashMap.Strict as HM
import App (AppM)
import Core.User (ExternalToken (ExternalToken, expiresAt), Service (Github, Discord, Spotify, Google, Twitter, Anilist))
import Core.User (ExternalToken (ExternalToken, accessToken, providerId, expiresAt), Service (Github, Discord, Spotify, Google, Twitter, Anilist))
import Data.Aeson.Types (Object, Value (String))
import Data.Text (Text, pack, unpack)
import Network.HTTP.Simple (JSONException, addRequestHeader, getResponseBody, httpJSONEither, parseRequest, setRequestMethod, setRequestQueryString, setRequestBodyURLEncoded)
import Network.HTTP.Simple (JSONException, addRequestHeader, getResponseBody, httpJSONEither, parseRequest, setRequestMethod, setRequestQueryString, setRequestBodyURLEncoded, setRequestBodyJSON, setRequestBodyLBS)
import System.Environment.MrEnv (envAsBool, envAsInt, envAsInteger, envAsString)
import Utils (lookupObjString, lookupObjInt)
import Data.ByteString.Base64
import Utils (lookupObjString, lookupObjObject, lookupObjInt)
import Control.Monad.Trans.Maybe (MaybeT (MaybeT, runMaybeT))
import Control.Monad.IO.Class (MonadIO(liftIO))
import Control.Monad (MonadPlus (mzero))
import Data.Aeson (decode)
import Data.ByteString.Base64 ( encodeBase64 )
import Data.Time (getCurrentTime, addUTCTime)
data OAuth2Conf = OAuth2Conf
{ oauthClientId :: String
@@ -33,6 +37,10 @@ tokenEndpoint code oc =
, code
]
liftMaybe :: (MonadPlus m) => Maybe a -> m a
liftMaybe = maybe mzero return
-- GITHUB
getGithubConfig :: IO OAuth2Conf
getGithubConfig =
@@ -41,8 +49,8 @@ getGithubConfig =
<*> envAsString "GITHUB_SECRET" ""
<*> pure "https://github.com/login/oauth/access_token"
getGithubTokens :: String -> IO (Maybe ExternalToken)
getGithubTokens code = do
getGithubTokens :: String -> MaybeT IO ExternalToken
getGithubTokens code = MaybeT $ do
gh <- getGithubConfig
let endpoint = tokenEndpoint code gh
request' <- parseRequest endpoint
@@ -57,11 +65,30 @@ getGithubTokens code = do
request'
response <- httpJSONEither request
currTime <- getCurrentTime
return $ case (getResponseBody response :: Either JSONException Object) of
Left _ -> Nothing
Right obj -> do
access <- lookupObjString obj "access_token"
Just $ ExternalToken (pack access) "" currTime Github
let (Right obj) = (getResponseBody response :: Either JSONException Object)
access <- liftMaybe $ lookupObjString obj "access_token"
let t = ExternalToken access "" currTime Github Nothing
githubId <- runMaybeT $ getGithubId t
return $ Just $ t { providerId = githubId }
rightToMaybe :: Either a b -> Maybe b
rightToMaybe = either (const Nothing) Just
getGithubId :: ExternalToken -> MaybeT IO Text
getGithubId t = MaybeT $ do
let endpoint = "https://api.github.com/user"
request' <- parseRequest endpoint
let request =
addRequestHeader "Authorization" (B8.pack ("token " ++ unpack (accessToken t))) $
addRequestHeader "Accept" "application/json" $
addRequestHeader "User-Agent" "aeris-server"
request'
response <- httpJSONEither request
case (getResponseBody response :: Either JSONException Object) of
Left err -> return Nothing
Right obj -> case lookupObjInt obj "id" of
Just githubId -> return $ Just $ pack $ show githubId
_ -> return Nothing
-- DISCORD
getDiscordConfig :: IO OAuth2Conf
@@ -71,8 +98,8 @@ getDiscordConfig =
<*> envAsString "DISCORD_SECRET" ""
<*> pure "https://discord.com/api/oauth2/token"
getDiscordTokens :: String -> IO (Maybe ExternalToken)
getDiscordTokens code = do
getDiscordTokens :: String -> MaybeT IO ExternalToken
getDiscordTokens code = MaybeT $ do
cfg <- getDiscordConfig
backUrl <- envAsString "BACK_URL" ""
let endpoint = tokenEndpoint code cfg
@@ -89,15 +116,30 @@ getDiscordTokens code = do
]
request'
response <- httpJSONEither request
let (Right obj) = (getResponseBody response :: Either JSONException Object)
access <- liftMaybe $ lookupObjString obj "access_token"
refresh <- liftMaybe $ lookupObjString obj "refresh_token"
currTime <- getCurrentTime
return $ case (getResponseBody response :: Either JSONException Object) of
Left _ -> Nothing
Right obj -> do
access <- lookupObjString obj "access_token"
refresh <- lookupObjString obj "refresh_token"
expiresIn <- lookupObjInt obj "expires_in"
let expiresAt = addUTCTime (fromInteger . fromIntegral $ expiresIn) currTime
Just $ ExternalToken (pack access) (pack refresh) expiresAt Discord
expiresIn <- liftMaybe $ lookupObjInt obj "expires_in"
let expiresAt = addUTCTime (fromInteger . fromIntegral $ expiresIn) currTime
let t = ExternalToken access refresh expiresAt Discord Nothing
discordId <- runMaybeT $ getDiscordId t
return $ Just $ t { providerId = discordId }
getDiscordId :: ExternalToken -> MaybeT IO Text
getDiscordId t = MaybeT $ do
let endpoint = "https://discord.com/api/users/@me"
request' <- parseRequest endpoint
let request =
addRequestHeader "Accept" "application/json" $
addRequestHeader "Authorization" (B8.pack $ "Bearer " ++ unpack (accessToken t))
request'
response <- httpJSONEither request
let (Right obj) = (getResponseBody response :: Either JSONException Object)
return $ lookupObjString obj "id"
-- GOOGLE
getGoogleConfig :: IO OAuth2Conf
@@ -107,8 +149,8 @@ getGoogleConfig =
<*> envAsString "GOOGLE_SECRET" ""
<*> pure "https://oauth2.googleapis.com/token"
getGoogleTokens :: String -> IO (Maybe ExternalToken)
getGoogleTokens code = do
getGoogleTokens :: String -> MaybeT IO ExternalToken
getGoogleTokens code = MaybeT $ do
cfg <- getGoogleConfig
backUrl <- envAsString "BACK_URL" ""
let endpoint = tokenEndpoint code cfg
@@ -126,14 +168,28 @@ getGoogleTokens code = do
request'
response <- httpJSONEither request
currTime <- getCurrentTime
return $ case (getResponseBody response :: Either JSONException Object) of
Left _ -> Nothing
Right obj -> do
access <- lookupObjString obj "access_token"
refresh <- lookupObjString obj "refresh_token"
expiresIn <- lookupObjInt obj "expires_in"
let expiresAt = addUTCTime (fromInteger . fromIntegral $ expiresIn) currTime
Just $ ExternalToken (pack access) (pack refresh) expiresAt Google
let (Right obj) = (getResponseBody response :: Either JSONException Object)
access <- liftMaybe $ lookupObjString obj "access_token"
refresh <- liftMaybe $ lookupObjString obj "refresh_token"
expiresIn <- liftMaybe $ lookupObjInt obj "expires_in"
let expiresAt = addUTCTime (fromInteger . fromIntegral $ expiresIn) currTime
let t = ExternalToken access refresh expiresAt Google Nothing
googleId <- runMaybeT $ getGoogleId t
return $ Just $ t { providerId = googleId }
getGoogleId :: ExternalToken -> MaybeT IO Text
getGoogleId t = MaybeT $ do
let endpoint = "https://oauth2.googleapis.com/tokeninfo"
request' <- parseRequest endpoint
let request =
addRequestHeader "Accept" "application/json" $
setRequestQueryString
[ ("access_token", Just . B8.pack . unpack $ accessToken t)
]
request'
response <- httpJSONEither request
let (Right obj) = (getResponseBody response :: Either JSONException Object)
return $ lookupObjString obj "sub"
-- SPOTIFY
getSpotifyConfig :: IO OAuth2Conf
@@ -143,8 +199,8 @@ getSpotifyConfig =
<*> envAsString "SPOTIFY_SECRET" ""
<*> pure "https://accounts.spotify.com/api/token"
getSpotifyTokens :: String -> IO (Maybe ExternalToken)
getSpotifyTokens code = do
getSpotifyTokens :: String -> MaybeT IO ExternalToken
getSpotifyTokens code = MaybeT $ do
cfg <- getSpotifyConfig
backUrl <- envAsString "BACK_URL" ""
let basicAuth = encodeBase64 $ B8.pack $ oauthClientId cfg ++ ":" ++ oauthClientSecret cfg
@@ -162,14 +218,26 @@ getSpotifyTokens code = do
request'
response <- httpJSONEither request
currTime <- getCurrentTime
return $ case (getResponseBody response :: Either JSONException Object) of
Left _ -> Nothing
Right obj -> do
access <- lookupObjString obj "access_token"
refresh <- lookupObjString obj "refresh_token"
expiresIn <- lookupObjInt obj "expires_in"
let expiresAt = addUTCTime (fromInteger . fromIntegral $ expiresIn) currTime
Just $ ExternalToken (pack access) (pack refresh) expiresAt Spotify
let (Right obj) = (getResponseBody response :: Either JSONException Object)
access <- liftMaybe $ lookupObjString obj "access_token"
refresh <- liftMaybe $ lookupObjString obj "refresh_token"
expiresIn <- liftMaybe $ lookupObjInt obj "expires_in"
let expiresAt = addUTCTime (fromInteger . fromIntegral $ expiresIn) currTime
let t = ExternalToken access refresh expiresAt Spotify Nothing
spotifyId <- runMaybeT $ getSpotifyId t
return $ Just $ t { providerId = spotifyId }
getSpotifyId :: ExternalToken -> MaybeT IO Text
getSpotifyId t = MaybeT $ do
let endpoint = "https://api.spotify.com/v1/me"
request' <- parseRequest endpoint
let request =
addRequestHeader "Content-Type" "application/json" $
addRequestHeader "Authorization" (B8.pack $ "Bearer " ++ unpack (accessToken t))
request'
response <- httpJSONEither request
let (Right obj) = (getResponseBody response :: Either JSONException Object)
return $ lookupObjString obj "id"
-- TWITTER
getTwitterConfig :: IO OAuth2Conf
@@ -179,8 +247,8 @@ getTwitterConfig =
<*> envAsString "TWITTER_SECRET" ""
<*> pure "https://api.twitter.com/2/oauth2/token"
getTwitterTokens :: String -> IO (Maybe ExternalToken)
getTwitterTokens code = do
getTwitterTokens :: String -> MaybeT IO ExternalToken
getTwitterTokens code = MaybeT $ do
cfg <- getTwitterConfig
backUrl <- envAsString "BACK_URL" ""
let basicAuth = encodeBase64 $ B8.pack $ "Basic " ++ oauthClientId cfg ++ ":" ++ oauthClientSecret cfg
@@ -199,14 +267,28 @@ getTwitterTokens code = do
request'
response <- httpJSONEither request
currTime <- getCurrentTime
return $ case (getResponseBody response :: Either JSONException Object) of
Left _ -> Nothing
Right obj -> do
access <- lookupObjString obj "access_token"
refresh <- lookupObjString obj "refresh_token"
expiresIn <- lookupObjInt obj "expires_in"
let expiresAt = addUTCTime (fromInteger . fromIntegral $ expiresIn) currTime
Just $ ExternalToken (pack access) (pack refresh) expiresAt Twitter
let (Right obj) = (getResponseBody response :: Either JSONException Object)
access <- liftMaybe $ lookupObjString obj "access_token"
refresh <- liftMaybe $ lookupObjString obj "refresh_token"
expiresIn <- liftMaybe $ lookupObjInt obj "expires_in"
let expiresAt = addUTCTime (fromInteger . fromIntegral $ expiresIn) currTime
let t = ExternalToken access refresh expiresAt Twitter Nothing
twitterId <- runMaybeT $ getTwitterId t
return $ Just $ t { providerId = twitterId }
getTwitterId :: ExternalToken -> MaybeT IO Text
getTwitterId t = MaybeT $ do
let endpoint = "https://api.twitter.com/2/users/me"
request' <- parseRequest endpoint
let request =
addRequestHeader "Content-Type" "application/json" $
addRequestHeader "Authorization" (B8.pack $ "Bearer " ++ unpack (accessToken t))
request'
response <- httpJSONEither request
let (Right obj) = (getResponseBody response :: Either JSONException Object)
case lookupObjObject obj "data" of
Just dataBody -> return $ lookupObjString dataBody "id"
_ -> return Nothing
-- ANILIST
getAnilistConfig :: IO OAuth2Conf
@@ -216,8 +298,8 @@ getAnilistConfig =
<*> envAsString "ANILIST_SECRET" ""
<*> pure "https://anilist.co/api/v2/oauth/token"
getAnilistTokens :: String -> IO (Maybe ExternalToken)
getAnilistTokens code = do
getAnilistTokens :: String -> MaybeT IO ExternalToken
getAnilistTokens code = MaybeT $ do
cfg <- getAnilistConfig
backUrl <- envAsString "BACK_URL" ""
let endpoint = tokenEndpoint code cfg
@@ -235,20 +317,40 @@ getAnilistTokens code = do
request'
response <- httpJSONEither request
currTime <- getCurrentTime
return $ case (getResponseBody response :: Either JSONException Object) of
Left _ -> Nothing
Right obj -> do
access <- lookupObjString obj "access_token"
refresh <- lookupObjString obj "refresh_token"
expiresIn <- lookupObjInt obj "expires_in"
let expiresAt = addUTCTime (fromInteger . fromIntegral $ expiresIn) currTime
Just $ ExternalToken (pack access) (pack refresh) expiresAt Anilist
let (Right obj) = (getResponseBody response :: Either JSONException Object)
access <- liftMaybe $ lookupObjString obj "access_token"
refresh <- liftMaybe $ lookupObjString obj "refresh_token"
expiresIn <- liftMaybe $ lookupObjInt obj "expires_in"
let expiresAt = addUTCTime (fromInteger . fromIntegral $ expiresIn) currTime
let t = ExternalToken access refresh expiresAt Anilist Nothing
anilistId <- runMaybeT $ getAnilistId t
return $ Just $ t { providerId = anilistId }
getAnilistId :: ExternalToken -> MaybeT IO Text
getAnilistId t = MaybeT $ do
let endpoint = "https://graphql.anilist.co"
request' <- parseRequest endpoint
let query = (decode "{Viewer {id,}}" :: Maybe Object)
let request =
addRequestHeader "Content-Type" "application/json" $
addRequestHeader "Authorization" (B8.pack $ "Bearer " ++ unpack (accessToken t)) $
addRequestHeader "User-Agent" "aeris-server" $
setRequestMethod "POST" $
setRequestBodyLBS "{\"query\": \"{Viewer {id}}\"}"
request'
response <- httpJSONEither request
let (Right obj) = (getResponseBody response :: Either JSONException Object)
case lookupObjObject obj "data" of
Just dataBody -> case liftMaybe $ lookupObjObject dataBody "Viewer" of
Just viewer -> case lookupObjInt viewer "id" of
Just anilistId -> return . Just . pack . show $ anilistId
_ -> return Nothing
_ -> return Nothing
_ -> return Nothing
-- General
getOauthTokens :: Service -> String -> IO (Maybe ExternalToken)
getOauthTokens :: Service -> String -> MaybeT IO ExternalToken
getOauthTokens Github = getGithubTokens
getOauthTokens Discord = getDiscordTokens
getOauthTokens Spotify = getSpotifyTokens
+1
View File
@@ -44,6 +44,7 @@ data ExternalToken = ExternalToken
, refreshToken :: Text
, expiresAt :: UTCTime
, service :: Service
, providerId :: Maybe Text
}
deriving (Eq, Show, Generic)
deriving anyclass (ToJSON, FromJSON)
+23 -4
View File
@@ -1,12 +1,14 @@
module Repository.User where
import App (AppM)
import Core.User (ExternalToken, UserId, Service)
import Core.User (ExternalToken (service, providerId), UserId, Service)
import Data.Text (Text)
import Db.User (User', getUserByName, getUserTokensById, insertUser, selectAllUser, updateUserTokens, getUserById, updateDelTokens)
import Db.User (User', getUserByName, getUserTokensById, insertUser, selectAllUser, updateUserTokens, getUserById, updateDelTokens, UserDB (externalTokens))
import Rel8 (insert, select, update, limit, lit)
import Repository.Utils (runQuery)
import Data.Int (Int64)
import Data.List (find)
import Servant (err401, throwError)
users :: AppM [User']
users = runQuery (select selectAllUser)
@@ -19,8 +21,25 @@ getUserById' uid = do
res <- runQuery (select $ limit 1 $ getUserById (lit uid))
return $ head res
createUser :: User' -> AppM [UserId]
createUser user = runQuery (insert $ insertUser user)
getUserByToken :: ExternalToken -> AppM User'
getUserByToken t = do
users' <- users
case find findByToken users' of
Nothing -> throwError err401
Just x -> return x
where
findByToken :: User' -> Bool
findByToken usr = do
let userTokens = externalTokens usr
case find (\tok -> service tok == service t) userTokens of
Nothing -> False
Just tok -> providerId tok == providerId t
createUser :: User' -> AppM User'
createUser user = do
ids <- runQuery (insert $ insertUser user)
getUserById' $ head ids
getTokensByUserId :: UserId -> AppM [ExternalToken]
getTokensByUserId uid = do
+12 -4
View File
@@ -3,6 +3,7 @@
{-# OPTIONS_GHC -Wno-deferred-out-of-scope-variables #-}
{-# LANGUAGE FlexibleInstances #-}
module Utils where
import Data.Aeson.Types (Value (String), Object)
@@ -18,16 +19,22 @@ import Db.Pipeline (Pipeline (Pipeline), PipelineId (PipelineId), pipelineLastTr
import Core.Pipeline (PipelineParams (PipelineParams))
import Data.Time (UTCTime (UTCTime), fromGregorian, secondsToDiffTime)
import Data.Default (Default, def)
import Data.Aeson (Value(Number, Object), decode)
import Data.Aeson (Value(Number, Object), decode, ToJSON, FromJSON)
import Data.Int (Int64)
import Data.Scientific ( toBoundedInteger )
import GHC.Generics (Generic)
mapInd :: (a -> Int -> b) -> [a] -> [b]
mapInd f l = zipWith f l [0 ..]
lookupObjString :: Object -> Text -> Maybe String
lookupObjString :: Object -> Text -> Maybe Text
lookupObjString obj key = case Data.HashMap.Strict.lookup key obj of
Just (String x) -> Just . unpack $ x
Just (String x) -> Just x
_ -> Nothing
lookupObjObject :: Object -> Text -> Maybe Object
lookupObjObject obj key = case Data.HashMap.Strict.lookup key obj of
Just (Object x) -> Just x
_ -> Nothing
@@ -55,4 +62,5 @@ instance Default (Pipeline Identity) where
}
type UserAuth = Servant.Auth.Server.Auth '[JWT] User
type AuthRes = Servant.Auth.Server.AuthResult User
type AuthRes = Servant.Auth.Server.AuthResult User
@@ -1,18 +0,0 @@
import { getCookie, sendServiceAuthToken } from "../../utils/utils";
import { useNavigate, useSearchParams } from "react-router-dom";
import React, { useEffect } from "react";
import { API_ROUTE } from "../../utils/globals";
export default function Anilist() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const authCode = searchParams.get("code") as string;
useEffect(() => {
sendServiceAuthToken(authCode, "/auth/anilist", `${window.location.origin}/authorization/anilist`).then((ok) => {
navigate('/pipelines');
});
}, []);
return <div />;
}
@@ -1,19 +0,0 @@
import { getCookie, sendServiceAuthToken } from "../../utils/utils";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useEffect } from "react";
import { API_ROUTE } from "../../utils/globals";
export default function DiscordAuth() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const authToken = searchParams.get("code") as string;
useEffect(() => {
sendServiceAuthToken(authToken, "/auth/discord", `${window.location.origin}/authorization/discord`).then((ok) => {
navigate('/pipelines');
});
}, []);
return <div />;
}
@@ -1,18 +0,0 @@
import { getCookie, sendServiceAuthToken } from "../../utils/utils";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useEffect } from "react";
import { API_ROUTE } from "../../utils/globals";
export default function GithubAuth() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const authCode = searchParams.get("code") as string;
useEffect(() => {
sendServiceAuthToken(authCode, "/auth/github", `${window.location.origin}/authorization/github`).then((ok) => {
navigate('/pipelines');
});
}, []);
return <div />;
}
@@ -0,0 +1,24 @@
import { useNavigate, useSearchParams } from "react-router-dom";
import { sendServiceAuthToken } from "../../utils/utils";
import { useEffect } from "react";
interface ServiceAuthProps {
service: string
endpoint: string
redirect_uri: string
navigate_to: string
}
export default function ServiceAuth({ service, endpoint, redirect_uri, navigate_to }: ServiceAuthProps) {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const authCode = searchParams.get("code") as string;
useEffect(() => {
sendServiceAuthToken(authCode, "/auth/" + service + endpoint, `${window.location.origin}/${redirect_uri}`).then((ok) => {
navigate(navigate_to);
})
}, []);
return <div />;
}
@@ -0,0 +1,27 @@
import { useNavigate, useSearchParams } from "react-router-dom";
import {sendServiceAuthToken, setCookie, signInService} from "../../utils/utils";
import { useEffect } from "react";
interface ServiceSignInProps {
service: string
endpoint: string
redirect_uri: string
navigate_to: string
}
export default function ServiceSignIn({ service, endpoint, redirect_uri, navigate_to }: ServiceSignInProps) {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const authCode = searchParams.get("code") as string;
useEffect(() => {
signInService(authCode, "/auth/" + service + endpoint, `${window.location.origin}/${redirect_uri}`).then((ok) => {
if (ok)
navigate(navigate_to);
else
console.warn('An error occurred when signing in with a service.');
})
}, []);
return <div />;
}
@@ -0,0 +1,241 @@
import { useNavigate, useSearchParams } from "react-router-dom";
import {getCookie, sendServiceAuthToken, setCookie, signInService} from "../../utils/utils";
import React, {useEffect, useState} from "react";
import Box from "@mui/material/Box";
import Card from "@material-ui/core/Card";
import CardContent from "@material-ui/core/CardContent";
import TextField from "@material-ui/core/TextField";
import {Divider, InputAdornment, Typography} from "@mui/material";
import {AccountCircle, Lock} from "@mui/icons-material";
import CardActions from "@material-ui/core/CardActions";
import Button from "@material-ui/core/Button";
import {makeStyles, Theme} from "@material-ui/core/styles";
import aerisTheme from "../../Aeris.theme";
import {t} from "i18next";
import {API_ROUTE} from "../../utils/globals";
const useStyles = makeStyles((theme: Theme) => ({
container: {
display: "absolute",
flex: 0.5,
margin: `${theme.spacing(0)} auto`,
},
loginBtn: {
display: "absolute",
backgroundColor: aerisTheme.palette.secondary.main,
color: aerisTheme.palette.primary.contrastText,
minWidth: 150,
margin: `${theme.spacing(0)} auto`,
'&:hover': {
backgroundColor: aerisTheme.palette.secondary.light
}
},
switchBtn: {
backgroundColor: aerisTheme.palette.primary.main,
color: aerisTheme.palette.primary.contrastText,
minWidth: 150,
margin: `${theme.spacing(0)} auto`,
'&:hover': {
backgroundColor: aerisTheme.palette.primary.light
}
},
media: {
display: "absolute",
justifyContent: "center",
alignItems: "center",
width: 354.75,
height: 478.5,
marginBottom: 5,
},
card: {
display: "absolute",
margin: `${theme.spacing(0)} auto`,
},
}));
interface ServiceSignUpProps {
service: string
endpoint: string
redirect_uri: string
navigate_to: string
}
type SignUpFormData = {
username: string;
password: string;
confirmedPassword: string;
isButtonDisabled: boolean;
helperText: string;
isError: boolean;
}
const requestSignUpWithService = async (username: string, password: string, service: string, authToken: string): Promise<boolean> => {
const response = await fetch(`${API_ROUTE}/auth/${service}/signup?code=${authToken}`, {
method: 'POST',
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({ username: username, password: password })
});
if (!response.ok) return false;
let json = await response.json();
setCookie("aeris_jwt", json["jwt"], 365);
return true;
}
export default function ServiceSignUp({ service, endpoint, redirect_uri, navigate_to }: ServiceSignUpProps) {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const classes = useStyles();
const [signUpData, setSignUpData] = useState<SignUpFormData>({
username: "",
password: "",
confirmedPassword: "",
isButtonDisabled: true,
helperText: "",
isError: false
});
const authCode = searchParams.get("code") as string;
useEffect(() => {
setSignUpData((prevState) => {
return {
...prevState,
isButtonDisabled: !(
signUpData.username.trim() &&
signUpData.password.trim() &&
(signUpData.confirmedPassword.trim() == signUpData.password.trim())
),
};
});
}, [signUpData.username, signUpData.password, signUpData.confirmedPassword]);
const handleSignUp = async () => {
if (await requestSignUpWithService(signUpData.username, signUpData.password, service, authCode)) {
setSignUpData((prevState) => {
return { ...prevState, isError: false, helperText: t('loginSuccess') };
});
window.location.href = "/pipelines";
} else {
setSignUpData((prevState) => {
return { ...prevState, isError: true, helperText: t('usernameOrPasswordIncorrect') };
});
}
};
const handleKeyPress = (event: React.KeyboardEvent) => {
if (event.key === "Enter") {
signUpData.isButtonDisabled || handleSignUp();
}
};
const handleUsernameChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setSignUpData((prevState) => {
return { ...prevState, username: event.target.value };
});
};
const handlePasswordChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setSignUpData((prevState) => {
return { ...prevState, password: event.target.value };
});
};
const handleConfirmedPasswordChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setSignUpData((prevState) => {
return { ...prevState, confirmedPassword: event.target.value };
});
};
return (
<div>
<Box component="img" className={classes.media} alt="Aeris Logo" src={require("../../assets/logo-white.png")} />
<form className={classes.container} noValidate autoComplete="on">
<Card className={classes.card}>
<CardContent>
<div>
<TextField
error={signUpData.isError}
required
type="username"
label={t("username") as string}
placeholder={t("username") as string}
margin="normal"
variant="outlined"
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<AccountCircle />
</InputAdornment>
),
}}
onKeyPress={handleKeyPress}
onChange={handleUsernameChange}
/>
<br />
<TextField
className="inputRounded"
error={signUpData.isError}
required
type="password"
label={t("password") as string}
placeholder={t("password") as string}
margin="normal"
variant="outlined"
size="small"
helperText={signUpData.helperText}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock />
</InputAdornment>
),
}}
onKeyPress={handleKeyPress}
onChange={handlePasswordChange}
/>
<br />
<TextField
className="inputRounded"
error={signUpData.isError}
required
type="password"
label={t('confirm_password') as string}
placeholder={t("password") as string}
margin="normal"
variant="outlined"
size="small"
helperText={signUpData.helperText}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock />
</InputAdornment>
),
}}
onKeyPress={handleKeyPress}
onChange={handleConfirmedPasswordChange}
/>
</div>
</CardContent>
<CardActions>
<Button
variant="contained"
size="large"
className={classes.loginBtn}
onClick={handleSignUp}
disabled={signUpData.isButtonDisabled}>
{t('signUp')}
</Button>
</CardActions>
</Card>
</form>
</div>
);
}
@@ -1,19 +0,0 @@
import { getCookie, sendServiceAuthToken } from "../../utils/utils";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useEffect } from "react";
import { API_ROUTE } from "../../utils/globals";
export default function SpotifyAuth() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const authCode = searchParams.get("code") as string;
useEffect(() => {
sendServiceAuthToken(authCode, "/auth/spotify", `${window.location.origin}/authorization/spotify`).then((ok) => {
navigate('/pipelines');
});
}, []);
return <div />;
}
@@ -1,19 +0,0 @@
import { getCookie, sendServiceAuthToken } from "../../utils/utils";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useEffect } from "react";
import { API_ROUTE } from "../../utils/globals";
export default function TwitterAuth() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const authCode = searchParams.get("code") as string;
useEffect(() => {
sendServiceAuthToken(authCode, "/auth/twitter", `${window.location.origin}/authorization/twitter`).then((ok) => {
navigate('/pipelines');
});
}, []);
return <div />;
}
@@ -1,19 +0,0 @@
import { getCookie, sendServiceAuthToken } from "../../utils/utils";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useEffect } from "react";
import { API_ROUTE } from "../../utils/globals";
export default function GoogleAuth() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const authCode = searchParams.get("code") as string;
useEffect(() => {
sendServiceAuthToken(authCode, "/auth/google", `${window.location.origin}/authorization/google`).then((ok) => {
navigate('/pipelines');
});
}, []);
return <div />;
}
+17 -13
View File
@@ -1,25 +1,26 @@
import { StrictMode } from "react";
import {StrictMode, useState} from "react";
import ReactDOM from "react-dom";
import "./App.css";
import App from "./App";
import GithubAuth from "./components/Authorizations/GithubAuth";
import SpotifyAuth from "./components/Authorizations/SpotifyAuth";
import GoogleAuth from "./components/Authorizations/YoutubeAuth";
import TwitterAuth from "./components/Authorizations/TwitterAuth";
import DiscordAuth from "./components/Authorizations/DiscordAuth";
import AnilistAuth from "./components/Authorizations/AnilistAuth";
import ServiceAuth from "./components/Authorizations/ServiceAuth";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import AuthComponent from "./pages/Login/LoginPage";
import PipelinePage from "./pages/HomePage";
import { ThemeProvider } from "@mui/material";
import theme from "./Aeris.theme";
import {AppServices} from "./utils/globals";
import {AppServiceType} from "./utils/types";
import ServiceSignIn from "./components/Authorizations/ServiceSignIn";
import ServiceSignUp from "./components/Authorizations/ServiceSignUp";
/**
* Creates the routing tree.
*/
function AerisRouter() {
const [possibleServices, setServices] = useState<Array<AppServiceType>>(AppServices)
return (
<ThemeProvider theme={theme}>
<div className="App">
@@ -29,12 +30,15 @@ function AerisRouter() {
<Route path="/" element={<App />} />
<Route path="/auth" element={<AuthComponent />} />
<Route path="/pipelines" element={<PipelinePage />} />
<Route path="/authorization/github" element={<GithubAuth />} />
<Route path="/authorization/spotify" element={<SpotifyAuth />} />
<Route path="/authorization/google" element={<GoogleAuth />} />
<Route path="/authorization/twitter" element={<TwitterAuth />} />
<Route path="/authorization/discord" element={<DiscordAuth />} />
<Route path="/authorization/anilist" element={<AnilistAuth />} />
{possibleServices.map((elem, index) => {
return (<Route path={`/authorization/${elem.uid}`} element={<ServiceAuth service={elem.uid} endpoint="" navigate_to="/pipelines" redirect_uri={`authorization/${elem.uid}`}/>} />);
})}
{possibleServices.map((elem, index) => {
return (<Route path={`/signin/${elem.uid}`} element={<ServiceSignIn service={elem.uid} endpoint="/signin" navigate_to="/pipelines" redirect_uri={`singin/${elem.uid}`}/>} />);
})}
{possibleServices.map((elem, index) => {
return (<Route path={`/signup/${elem.uid}`} element={<ServiceSignUp service={elem.uid} endpoint="/signup" navigate_to="/pipelines" redirect_uri={`singup/${elem.uid}`}/>} />);
})}
</Routes>
</BrowserRouter>
</header>
+25 -2
View File
@@ -4,9 +4,9 @@ import { makeStyles, Theme } from "@material-ui/core/styles";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import { AccountCircle, Cookie, Lock } from "@mui/icons-material";
import { InputAdornment } from "@mui/material";
import {CardMedia, Divider, InputAdornment, Typography} from "@mui/material";
import { API_ROUTE } from "../../utils/globals";
import {API_ROUTE, AppServices} from "../../utils/globals";
import CardContent from "@material-ui/core/CardContent";
import CardActions from "@material-ui/core/CardActions";
@@ -20,6 +20,7 @@ import { setCookie, getCookie } from "../../utils/utils";
import { useTranslation } from "react-i18next";
import '../../i18n/config';
import {AppServiceType} from "../../utils/types";
const useStyles = makeStyles((theme: Theme) => ({
container: {
@@ -91,6 +92,7 @@ export default function AuthComponent() {
const { t } = useTranslation();
const classes = useStyles();
const navigate = useNavigate();
const [servicesData, setServicesData] = useState<Array<AppServiceType>>(AppServices);
const [authData, setAuthData] = useState<AuthCompProps>({
username: "",
password: "",
@@ -253,6 +255,27 @@ export default function AuthComponent() {
{authData.authMode === "login" ? t('signUp') : t('connectToAeris')}
</Button>
</CardActions>
<Divider variant="middle" sx={{ m: 1 }}/>
<Typography variant="body2" sx={{ mb: 1}}>
Or you can sign up with this services:
</Typography>
<CardActions>
{servicesData.map((elem, index) => {
if (elem.uid === "utils")
return (<div/>);
return (
<Button
onClick={() => {
authData.authMode === 'login' ? (window.location.href = elem.signinUrl) : (window.location.href = elem.signupUrl);
}}
variant="text"
style={{ borderRadius: "20px", width: "20px", height: "20px" }}
>
<img loading="lazy" width="20px" height="20px" src={elem.logo.imageSrc} alt={elem.logo.altText} />
</Button>
);
})}
</CardActions>
</Card>
</form>
</div>
+15 -3
View File
@@ -42,8 +42,8 @@ export const AppServicesLogos: { [key: string]: ImageProps } = {
},
};
const getServiceUrl = (service: string) =>
`${API_ROUTE}/auth/${service}/url?redirect_uri=${window.location.origin}/authorization/${service}`;
const getServiceUrl = (service: string, method: string = "authorization") =>
`${API_ROUTE}/auth/${service}/url?redirect_uri=${window.location.origin}/${method}/${service}`;
export const AppServices: Array<AppServiceType> = [
{
@@ -51,6 +51,8 @@ export const AppServices: Array<AppServiceType> = [
uid: "google",
logo: AppServicesLogos["youtube"],
urlAuth: getServiceUrl("google"),
signinUrl: getServiceUrl("google", "signin"),
signupUrl: getServiceUrl("google", "signup"),
linked: false,
},
{
@@ -58,6 +60,8 @@ export const AppServices: Array<AppServiceType> = [
uid: "spotify",
logo: AppServicesLogos["spotify"],
urlAuth: getServiceUrl("spotify"),
signinUrl: getServiceUrl("spotify", "signin"),
signupUrl: getServiceUrl("spotify", "signup"),
linked: false,
},
{
@@ -65,6 +69,8 @@ export const AppServices: Array<AppServiceType> = [
uid: "github",
logo: AppServicesLogos["github"],
urlAuth: getServiceUrl("github"),
signinUrl: getServiceUrl("github", "signin"),
signupUrl: getServiceUrl("github", "signup"),
linked: false,
},
{
@@ -72,6 +78,8 @@ export const AppServices: Array<AppServiceType> = [
uid: "twitter",
logo: AppServicesLogos["twitter"],
urlAuth: getServiceUrl("twitter"),
signinUrl: getServiceUrl("twitter", "signin"),
signupUrl: getServiceUrl("twitter", "signup"),
linked: false,
},
{
@@ -79,6 +87,8 @@ export const AppServices: Array<AppServiceType> = [
uid: "discord",
logo: AppServicesLogos["discord"],
urlAuth: getServiceUrl("discord"),
signinUrl: getServiceUrl("discord", "signin"),
signupUrl: getServiceUrl("discord", "signup"),
linked: false,
},
{
@@ -86,6 +96,8 @@ export const AppServices: Array<AppServiceType> = [
uid: "anilist",
logo: AppServicesLogos["anilist"],
urlAuth: getServiceUrl("anilist"),
signinUrl: getServiceUrl("anilist", "signin"),
signupUrl: getServiceUrl("anilist", "signup"),
linked: false,
},
{
@@ -93,7 +105,7 @@ export const AppServices: Array<AppServiceType> = [
uid: "utils",
logo: AppServicesLogos["utils"],
urlAuth: "",
linked: true,
linked: true
},
];
+2
View File
@@ -10,6 +10,8 @@ export interface AppServiceType {
uid: string;
logo: ImageProps;
urlAuth: string;
signinUrl: string;
signupUrl: string;
linked: boolean;
}
+19
View File
@@ -37,6 +37,25 @@ export const sendServiceAuthToken = async (
return response.ok;
};
export const signInService = async (
authToken: string,
serviceEndpoint: string,
redirectUri: string
): Promise<boolean> => {
const response = await fetch(`${API_ROUTE}${serviceEndpoint}?code=${authToken}&redirect_uri=${redirectUri}`, {
method: "POST",
headers: {
Accept: 'application/json',
"Content-Type": "application/json"
}
});
if (!response.ok) return false;
let json = await response.json();
setCookie("aeris_jwt", json['jwt'], 365);
return response.ok;
};
export const PipelineParamsToApiParam = (pipelineParams: { [key: string]: ParamsType }) => {
return Object.fromEntries(Object.entries(pipelineParams).map((el) => [el[0], el[1].value]));
};