diff --git a/api/src/Api/About.hs b/api/src/Api/About.hs index e24446c..4885b2c 100644 --- a/api/src/Api/About.hs +++ b/api/src/Api/About.hs @@ -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 diff --git a/api/src/Api/Auth.hs b/api/src/Api/Auth.hs index c2d4a5c..45de609 100644 --- a/api/src/Api/Auth.hs +++ b/api/src/Api/Auth.hs @@ -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 diff --git a/api/src/Api/OIDC.hs b/api/src/Api/OIDC.hs index 4eb3cdd..52b8dba 100644 --- a/api/src/Api/OIDC.hs +++ b/api/src/Api/OIDC.hs @@ -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 \ No newline at end of file diff --git a/api/src/Api/Worker.hs b/api/src/Api/Worker.hs index d701889..b3ac08a 100644 --- a/api/src/Api/Worker.hs +++ b/api/src/Api/Worker.hs @@ -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 diff --git a/api/src/Core/OIDC.hs b/api/src/Core/OIDC.hs index 2d7d9f6..7affee2 100644 --- a/api/src/Core/OIDC.hs +++ b/api/src/Core/OIDC.hs @@ -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 diff --git a/api/src/Core/User.hs b/api/src/Core/User.hs index 3a7f7b2..246f81c 100644 --- a/api/src/Core/User.hs +++ b/api/src/Core/User.hs @@ -44,6 +44,7 @@ data ExternalToken = ExternalToken , refreshToken :: Text , expiresAt :: UTCTime , service :: Service + , providerId :: Maybe Text } deriving (Eq, Show, Generic) deriving anyclass (ToJSON, FromJSON) diff --git a/api/src/Repository/User.hs b/api/src/Repository/User.hs index ce3ef9b..a1659ae 100644 --- a/api/src/Repository/User.hs +++ b/api/src/Repository/User.hs @@ -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 diff --git a/api/src/Utils.hs b/api/src/Utils.hs index 7a35ef2..bb6ab89 100644 --- a/api/src/Utils.hs +++ b/api/src/Utils.hs @@ -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 \ No newline at end of file +type AuthRes = Servant.Auth.Server.AuthResult User + diff --git a/web-app/src/components/Authorizations/AnilistAuth.tsx b/web-app/src/components/Authorizations/AnilistAuth.tsx deleted file mode 100644 index 278e038..0000000 --- a/web-app/src/components/Authorizations/AnilistAuth.tsx +++ /dev/null @@ -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
; -} diff --git a/web-app/src/components/Authorizations/DiscordAuth.tsx b/web-app/src/components/Authorizations/DiscordAuth.tsx deleted file mode 100644 index c22fb17..0000000 --- a/web-app/src/components/Authorizations/DiscordAuth.tsx +++ /dev/null @@ -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
; -} diff --git a/web-app/src/components/Authorizations/GithubAuth.tsx b/web-app/src/components/Authorizations/GithubAuth.tsx deleted file mode 100644 index e9c2757..0000000 --- a/web-app/src/components/Authorizations/GithubAuth.tsx +++ /dev/null @@ -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
; -} diff --git a/web-app/src/components/Authorizations/ServiceAuth.tsx b/web-app/src/components/Authorizations/ServiceAuth.tsx new file mode 100644 index 0000000..04732d2 --- /dev/null +++ b/web-app/src/components/Authorizations/ServiceAuth.tsx @@ -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
; +} \ No newline at end of file diff --git a/web-app/src/components/Authorizations/ServiceSignIn.tsx b/web-app/src/components/Authorizations/ServiceSignIn.tsx new file mode 100644 index 0000000..bcc03fd --- /dev/null +++ b/web-app/src/components/Authorizations/ServiceSignIn.tsx @@ -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
; +} \ No newline at end of file diff --git a/web-app/src/components/Authorizations/ServiceSignUp.tsx b/web-app/src/components/Authorizations/ServiceSignUp.tsx new file mode 100644 index 0000000..d5ddb27 --- /dev/null +++ b/web-app/src/components/Authorizations/ServiceSignUp.tsx @@ -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 => { + 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({ + 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 = (event) => { + setSignUpData((prevState) => { + return { ...prevState, username: event.target.value }; + }); + }; + + const handlePasswordChange: React.ChangeEventHandler = (event) => { + setSignUpData((prevState) => { + return { ...prevState, password: event.target.value }; + }); + }; + + const handleConfirmedPasswordChange: React.ChangeEventHandler = (event) => { + setSignUpData((prevState) => { + return { ...prevState, confirmedPassword: event.target.value }; + }); + }; + + return ( +
+ +
+ + +
+ + + + ), + }} + onKeyPress={handleKeyPress} + onChange={handleUsernameChange} + /> +
+ + + + ), + }} + onKeyPress={handleKeyPress} + onChange={handlePasswordChange} + /> +
+ + + + ), + }} + onKeyPress={handleKeyPress} + onChange={handleConfirmedPasswordChange} + /> +
+
+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/web-app/src/components/Authorizations/SpotifyAuth.tsx b/web-app/src/components/Authorizations/SpotifyAuth.tsx deleted file mode 100644 index 69ce0d7..0000000 --- a/web-app/src/components/Authorizations/SpotifyAuth.tsx +++ /dev/null @@ -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
; -} diff --git a/web-app/src/components/Authorizations/TwitterAuth.tsx b/web-app/src/components/Authorizations/TwitterAuth.tsx deleted file mode 100644 index c606adf..0000000 --- a/web-app/src/components/Authorizations/TwitterAuth.tsx +++ /dev/null @@ -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
; -} diff --git a/web-app/src/components/Authorizations/YoutubeAuth.tsx b/web-app/src/components/Authorizations/YoutubeAuth.tsx deleted file mode 100644 index e81da32..0000000 --- a/web-app/src/components/Authorizations/YoutubeAuth.tsx +++ /dev/null @@ -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
; -} diff --git a/web-app/src/index.tsx b/web-app/src/index.tsx index 236f033..c3eb0e3 100644 --- a/web-app/src/index.tsx +++ b/web-app/src/index.tsx @@ -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>(AppServices) + return (
@@ -29,12 +30,15 @@ function AerisRouter() { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> + {possibleServices.map((elem, index) => { + return (} />); + })} + {possibleServices.map((elem, index) => { + return (} />); + })} + {possibleServices.map((elem, index) => { + return (} />); + })} diff --git a/web-app/src/pages/Login/LoginPage.tsx b/web-app/src/pages/Login/LoginPage.tsx index 325d516..bec316d 100644 --- a/web-app/src/pages/Login/LoginPage.tsx +++ b/web-app/src/pages/Login/LoginPage.tsx @@ -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>(AppServices); const [authData, setAuthData] = useState({ username: "", password: "", @@ -253,6 +255,27 @@ export default function AuthComponent() { {authData.authMode === "login" ? t('signUp') : t('connectToAeris')} + + + Or you can sign up with this services: + + + {servicesData.map((elem, index) => { + if (elem.uid === "utils") + return (
); + return ( + + ); + })} +
diff --git a/web-app/src/utils/globals.tsx b/web-app/src/utils/globals.tsx index 9ae1c10..94d55a4 100644 --- a/web-app/src/utils/globals.tsx +++ b/web-app/src/utils/globals.tsx @@ -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 = [ { @@ -51,6 +51,8 @@ export const AppServices: Array = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ 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 = [ uid: "utils", logo: AppServicesLogos["utils"], urlAuth: "", - linked: true, + linked: true }, ]; diff --git a/web-app/src/utils/types.tsx b/web-app/src/utils/types.tsx index f14f24c..285167d 100644 --- a/web-app/src/utils/types.tsx +++ b/web-app/src/utils/types.tsx @@ -10,6 +10,8 @@ export interface AppServiceType { uid: string; logo: ImageProps; urlAuth: string; + signinUrl: string; + signupUrl: string; linked: boolean; } diff --git a/web-app/src/utils/utils.tsx b/web-app/src/utils/utils.tsx index 860ac4e..a0f1df2 100644 --- a/web-app/src/utils/utils.tsx +++ b/web-app/src/utils/utils.tsx @@ -37,6 +37,25 @@ export const sendServiceAuthToken = async ( return response.ok; }; +export const signInService = async ( + authToken: string, + serviceEndpoint: string, + redirectUri: string +): Promise => { + 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])); };