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