Enforce uniqueness between pending and verified emails
This commit is contained in:
parent
c7d35146ac
commit
80993bbf8c
|
@ -0,0 +1,40 @@
|
||||||
|
-- migrate:up
|
||||||
|
|
||||||
|
create table "emails" (
|
||||||
|
"id" serial primary key,
|
||||||
|
"uid" integer not null references "users" on delete cascade,
|
||||||
|
"email" varchar(320) unique not null, -- local 64 + domain 255 + '@' 1 per RFC5321
|
||||||
|
"verificationSecret" varchar(255) unique
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index "emails_uid_verified" on "emails" ("uid", ("verificationSecret" is not null));
|
||||||
|
-- at most one verified and one pending email per user
|
||||||
|
|
||||||
|
insert into "emails" ("uid", "email", "verificationSecret")
|
||||||
|
select "id", "email", null as "verificationSecret" from "users" where "email" is not null;
|
||||||
|
|
||||||
|
insert into "emails" ("uid", "email", "verificationSecret")
|
||||||
|
select "id", "pendingEmail", "emailVerificationSecret" from "users" where "pendingEmail" is not null;
|
||||||
|
|
||||||
|
alter table "users"
|
||||||
|
drop "email" cascade,
|
||||||
|
drop "pendingEmail" cascade,
|
||||||
|
drop "emailVerificationSecret" cascade;
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
|
||||||
|
alter table "users"
|
||||||
|
add "email" varchar(320) unique,
|
||||||
|
add "pendingEmail" varchar(320) unique,
|
||||||
|
add "emailVerificationSecret" varchar(255) unique;
|
||||||
|
|
||||||
|
update "users" set "email" = "emails"."email"
|
||||||
|
from "emails"
|
||||||
|
where "users"."id" = "emails"."uid" and "emails"."verificationSecret" is null;
|
||||||
|
|
||||||
|
update "users" set "pendingEmail" = "emails"."email",
|
||||||
|
"emailVerificationSecret" = "emails"."verificationSecret"
|
||||||
|
from "emails"
|
||||||
|
where "users"."id" = "emails"."uid" and "emails"."verificationSecret" is not null;
|
||||||
|
|
||||||
|
drop table "emails";
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
module Server.API (coreApp, runApp, resolver) where
|
module Server.API (coreApp, runApp, resolver) where
|
||||||
|
|
||||||
import Relude hiding (Undefined, void, when)
|
import Relude hiding (Undefined, void, when, get)
|
||||||
|
|
||||||
import "cryptonite" Crypto.Random (getRandomBytes, MonadRandom)
|
import "cryptonite" Crypto.Random (getRandomBytes, MonadRandom)
|
||||||
import Control.Monad.Except (MonadError, throwError)
|
import Control.Monad.Except (MonadError, throwError)
|
||||||
|
@ -27,7 +27,7 @@ import Data.Morpheus.Server (deriveApp, runApp)
|
||||||
import Data.Morpheus.Server.Types (defaultRootResolver, RootResolver(..), Undefined)
|
import Data.Morpheus.Server.Types (defaultRootResolver, RootResolver(..), Undefined)
|
||||||
import Data.Morpheus.Types (Arg(..), GQLType, GQLError, App)
|
import Data.Morpheus.Types (Arg(..), GQLType, GQLError, App)
|
||||||
import Data.Time (nominalDay)
|
import Data.Time (nominalDay)
|
||||||
import Database.Persist (Entity, entityVal, (=.))
|
import Database.Persist (Entity, entityVal, entityKey, get, (=.))
|
||||||
import Server.DB
|
import Server.DB
|
||||||
import Server.DB.Queries
|
import Server.DB.Queries
|
||||||
import Server.Email (sendVerificationEmail)
|
import Server.Email (sendVerificationEmail)
|
||||||
|
@ -50,15 +50,15 @@ void m = m >> pure Unit
|
||||||
when :: Monad m => Bool -> m a -> m Unit
|
when :: Monad m => Bool -> m a -> m Unit
|
||||||
when b m = if b then void m else pure Unit
|
when b m = if b then void m else pure Unit
|
||||||
|
|
||||||
dbUserToUser :: Monad m => Entity DBUser -> User m
|
dbUserToUser :: MonadDB m => Entity DBUser -> User m
|
||||||
dbUserToUser user = let id = entityToID user
|
dbUserToUser user = let id = entityToID user
|
||||||
DBUser {..} = entityVal user
|
DBUser {..} = entityVal user
|
||||||
Success (MemberData {..}) = fromJSON dBUserMemberData
|
Success (MemberData {..}) = fromJSON dBUserMemberData
|
||||||
-- XXX: Explodes if database doesn't contain needed data
|
-- XXX: Explodes if database doesn't contain needed data
|
||||||
in User
|
in User
|
||||||
{ id = pure id
|
{ id = pure id
|
||||||
, email = pure dBUserEmail
|
, email = fmap (dBEmailEmail . entityVal) <$> getUserEmail id
|
||||||
, pendingEmail = pure dBUserPendingEmail
|
, pendingEmail = fmap (dBEmailEmail . entityVal) <$> getUserPendingEmail id
|
||||||
, phoneNumber = pure phoneNumber
|
, phoneNumber = pure phoneNumber
|
||||||
, name = pure name
|
, name = pure name
|
||||||
, nickname = pure $ fromMaybe (error "db contains empty name") $
|
, nickname = pure $ fromMaybe (error "db contains empty name") $
|
||||||
|
@ -114,9 +114,7 @@ newUser (ApplicationData {..}) = do
|
||||||
when (T.null homeplace) $ throwError "Homeplace must not be empty"
|
when (T.null homeplace) $ throwError "Homeplace must not be empty"
|
||||||
let memberData = MemberData { nickname = nickname >>= \x -> if T.null x then Nothing else Just x, ..}
|
let memberData = MemberData { nickname = nickname >>= \x -> if T.null x then Nothing else Just x, ..}
|
||||||
user <- addUser $ DBUser
|
user <- addUser $ DBUser
|
||||||
{ dBUserEmail = Nothing
|
{ dBUserRegistered = time
|
||||||
, dBUserPendingEmail = Just email
|
|
||||||
, dBUserRegistered = time
|
|
||||||
, dBUserToBeDeleted = Just $ verificationExpires
|
, dBUserToBeDeleted = Just $ verificationExpires
|
||||||
, dBUserPasswordCrypt = passwordHash
|
, dBUserPasswordCrypt = passwordHash
|
||||||
, dBUserPermissions = permissions
|
, dBUserPermissions = permissions
|
||||||
|
@ -124,9 +122,13 @@ newUser (ApplicationData {..}) = do
|
||||||
, dBUserSeceded = Nothing
|
, dBUserSeceded = Nothing
|
||||||
, dBUserRejected = Nothing
|
, dBUserRejected = Nothing
|
||||||
, dBUserMemberData = toJSON memberData
|
, dBUserMemberData = toJSON memberData
|
||||||
, dBUserEmailVerificationSecret = Just secret
|
|
||||||
}
|
}
|
||||||
sendVerificationSecret user
|
email <- addEmail $ DBEmail
|
||||||
|
{ dBEmailUid = toDBKey user
|
||||||
|
, dBEmailEmail = email
|
||||||
|
, dBEmailVerificationSecret = Just secret
|
||||||
|
}
|
||||||
|
sendVerificationSecret email
|
||||||
return user
|
return user
|
||||||
|
|
||||||
verificationExpireTime :: MonadTime m => m Time
|
verificationExpireTime :: MonadTime m => m Time
|
||||||
|
@ -135,11 +137,11 @@ verificationExpireTime = addTime (7 * nominalDay) <$> currentTime
|
||||||
genVerificationSecret :: MonadRandom m => m Text
|
genVerificationSecret :: MonadRandom m => m Text
|
||||||
genVerificationSecret = T.intercalate "-" . T.chunksOf 4 . base32 <$> getRandomBytes 10
|
genVerificationSecret = T.intercalate "-" . T.chunksOf 4 . base32 <$> getRandomBytes 10
|
||||||
|
|
||||||
sendVerificationSecret :: (MonadEmail m, MonadDB m, MonadError GQLError m) => UserID -> m Unit
|
sendVerificationSecret :: (MonadEmail m, MonadDB m, MonadError GQLError m) => Key DBEmail -> m Unit
|
||||||
sendVerificationSecret user = void $ do
|
sendVerificationSecret email = void $ do
|
||||||
maybeDBUser <- fmap entityVal <$> getByID user
|
maybeDBEmail <- runQuery $ get email
|
||||||
let email = dBUserPendingEmail =<< maybeDBUser
|
let email = dBEmailEmail <$> maybeDBEmail
|
||||||
secret = dBUserEmailVerificationSecret =<< maybeDBUser
|
secret = dBEmailVerificationSecret =<< maybeDBEmail
|
||||||
args = (,) <$> email <*> secret
|
args = (,) <$> email <*> secret
|
||||||
maybe (pure ()) (uncurry sendVerificationEmail) args
|
maybe (pure ()) (uncurry sendVerificationEmail) args
|
||||||
|
|
||||||
|
@ -148,14 +150,16 @@ updateUser :: (MonadRandom m, MonadDB m, MonadEmail m, MonadError GQLError m) =>
|
||||||
updateUser user (UpdateData {..}) = do
|
updateUser user (UpdateData {..}) = do
|
||||||
hash <- sequence $ hashPassword <$> password
|
hash <- sequence $ hashPassword <$> password
|
||||||
-- TODO: assert stuff valid
|
-- TODO: assert stuff valid
|
||||||
verificationSecretUpdate <- maybe (pure Nothing)
|
|
||||||
(const $ Just . (DBUserEmailVerificationSecret =. ) . Just <$> genVerificationSecret) email
|
|
||||||
user <- updateUserData user
|
user <- updateUserData user
|
||||||
(catMaybes [(DBUserPendingEmail =. ) . Just <$> email, verificationSecretUpdate,
|
(catMaybes [(DBUserPasswordCrypt =.) <$> hash])
|
||||||
(DBUserPasswordCrypt =.) <$> hash])
|
|
||||||
(catMaybes [SetUserName <$> name, SetUserNickname . Just <$> nickname,
|
(catMaybes [SetUserName <$> name, SetUserNickname . Just <$> nickname,
|
||||||
SetUserHomeplace <$> homeplace, SetUserPhoneNumber <$> phoneNumber])
|
SetUserHomeplace <$> homeplace, SetUserPhoneNumber <$> phoneNumber])
|
||||||
when (isJust email) $ sendVerificationSecret user
|
case email of
|
||||||
|
Nothing -> pure Unit
|
||||||
|
Just email' -> do
|
||||||
|
verificationSecret <- genVerificationSecret
|
||||||
|
emailKey <- updateEmail user email' verificationSecret
|
||||||
|
sendVerificationSecret emailKey
|
||||||
return user
|
return user
|
||||||
|
|
||||||
makeNewToken :: (MonadError GQLError m, MonadDB m, MonadTime m, MonadRandom m, MonadPermissions m) =>
|
makeNewToken :: (MonadError GQLError m, MonadDB m, MonadTime m, MonadRandom m, MonadPermissions m) =>
|
||||||
|
@ -224,7 +228,8 @@ resolveMutation = Mutation
|
||||||
user <- fromMaybeFail "" maybeUser
|
user <- fromMaybeFail "" maybeUser
|
||||||
pure $ dbUserToUser user
|
pure $ dbUserToUser user
|
||||||
, verifyEmail = \(Arg secret) -> void $ verifyEmailSecret secret >>= \x -> when (x < 1) $ throwError "Invalid verification secret"
|
, verifyEmail = \(Arg secret) -> void $ verifyEmailSecret secret >>= \x -> when (x < 1) $ throwError "Invalid verification secret"
|
||||||
, resendVerificationEmail = \(Arg id) -> targetUser id >>= sendVerificationSecret
|
, resendVerificationEmail = \(Arg id) -> targetUser id >>= getUserPendingEmail >>=
|
||||||
|
maybe (pure Unit) (sendVerificationSecret . entityKey)
|
||||||
, update = \updateData (Arg id) -> targetUser id >>= \user ->
|
, update = \updateData (Arg id) -> targetUser id >>= \user ->
|
||||||
requirePermission (Profile user) ReadWrite >>
|
requirePermission (Profile user) ReadWrite >>
|
||||||
updateUser user updateData >> getByID user >>= fmap dbUserToUser . fromMaybeFail ""
|
updateUser user updateData >> getByID user >>= fmap dbUserToUser . fromMaybeFail ""
|
||||||
|
|
|
@ -24,9 +24,6 @@ import Server.Types
|
||||||
|
|
||||||
mkPersist sqlSettings [persistUpperCase|
|
mkPersist sqlSettings [persistUpperCase|
|
||||||
DBUser sql=users
|
DBUser sql=users
|
||||||
email (Maybe Email) sqltype=varchar(255)
|
|
||||||
pendingEmail (Maybe Email) sqltype=varchar(255)
|
|
||||||
emailVerificationSecret (Maybe Text)
|
|
||||||
registered Time
|
registered Time
|
||||||
passwordCrypt PasswordHash
|
passwordCrypt PasswordHash
|
||||||
permissions Text
|
permissions Text
|
||||||
|
@ -36,12 +33,21 @@ DBUser sql=users
|
||||||
toBeDeleted (Maybe Time)
|
toBeDeleted (Maybe Time)
|
||||||
memberData Value sqltype=jsonb
|
memberData Value sqltype=jsonb
|
||||||
|
|
||||||
UniqueEmail email
|
|
||||||
UniquePendingEmail pendingEmail
|
|
||||||
UniqueVerification emailVerificationSecret
|
|
||||||
|
|
||||||
deriving (Show)
|
deriving (Show)
|
||||||
|
|
||||||
|
DBEmail sql=emails
|
||||||
|
uid DBUserId
|
||||||
|
email Email sqltype=varchar(320)
|
||||||
|
verificationSecret (Maybe Text)
|
||||||
|
|
||||||
|
UniqueUserVerified uid verificationSecret
|
||||||
|
-- This enables using persistent functions to get unique verified emails. The real
|
||||||
|
-- constraint is stricter and doesn't allow having more than one null and one non-null
|
||||||
|
-- verification secret but it's too complicated for persistent to understand.
|
||||||
|
|
||||||
|
UniqueEmail email
|
||||||
|
UniqueVerification verificationSecret
|
||||||
|
|
||||||
DBKey sql=keys
|
DBKey sql=keys
|
||||||
uid DBUserId
|
uid DBUserId
|
||||||
data ByteString
|
data ByteString
|
||||||
|
|
|
@ -10,21 +10,22 @@ import Server.DB as DB
|
||||||
import Server.Types
|
import Server.Types
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import Database.Esqueleto.Experimental
|
import Database.Esqueleto.Experimental
|
||||||
import qualified Database.Persist as Persist (update, (=.))
|
import qualified Database.Persist as Persist (update, (=.), (==.))
|
||||||
import qualified Database.Persist.Types as Persist (Update)
|
import qualified Database.Persist.Types as Persist (Update)
|
||||||
import Data.Maybe (listToMaybe, isJust)
|
import Data.Maybe (listToMaybe)
|
||||||
import Data.Aeson (fromJSON, toJSON, Result(..))
|
import Data.Aeson (fromJSON, toJSON, Result(..))
|
||||||
import GHC.Int (Int64)
|
|
||||||
|
|
||||||
getByID :: (MonadDB m, ToDBKey k, PersistEntityBackend (DB k) ~ SqlBackend) => k -> m (Maybe (Entity (DB k)))
|
getByID :: (MonadDB m, ToDBKey k, PersistEntityBackend (DB k) ~ SqlBackend) => k -> m (Maybe (Entity (DB k)))
|
||||||
getByID id = let key = toDBKey id in runQuery $ fmap (Entity key) <$> get key
|
getByID id = let key = toDBKey id in runQuery $ fmap (Entity key) <$> get key
|
||||||
|
|
||||||
getUserByEmail :: MonadDB m => Email -> m (Maybe (Entity DBUser))
|
getUserByEmail :: MonadDB m => Email -> m (Maybe (Entity DBUser))
|
||||||
getUserByEmail email = runQuery $ getBy (UniqueEmail $ Just email) >>=
|
getUserByEmail email = fmap listToMaybe $ runQuery $ select $ do
|
||||||
maybe ((>>= guardUnconfirmed) <$> getBy (UniquePendingEmail $ Just email)) (pure . Just)
|
(dbUser :& dbEmail) <- from $ table @DBUser `crossJoin` table @DBEmail
|
||||||
where guardUnconfirmed user
|
where_ $ dbEmail ^. DBEmailEmail ==. val email &&. dbUser ^. DBUserId ==. dbEmail ^. DBEmailUid
|
||||||
| isJust (dBUserEmail $ entityVal user) = Nothing
|
-- There is only one row in DBEmail with a given email (unique constraint) and a DBEmail only
|
||||||
| otherwise = Just user
|
-- has one user id and there is only row in DBUser with a given user id (primary key). Thus
|
||||||
|
-- there is at most one combination of rows from DBEmail and DBUser that satisfy this query.
|
||||||
|
pure dbUser
|
||||||
|
|
||||||
addUser :: MonadDB m => DBUser -> m UserID
|
addUser :: MonadDB m => DBUser -> m UserID
|
||||||
addUser = fmap fromDBKey . runQuery . insert
|
addUser = fmap fromDBKey . runQuery . insert
|
||||||
|
@ -74,19 +75,61 @@ applicants = runQuery $ select $ do
|
||||||
where_ $ isApplicant users
|
where_ $ isApplicant users
|
||||||
pure $ users
|
pure $ users
|
||||||
|
|
||||||
|
isVerified :: SqlExpr (Entity DBEmail) -> SqlExpr (Value Bool)
|
||||||
|
isVerified email = isNothing (email ^. DBEmailVerificationSecret)
|
||||||
|
|
||||||
|
hasVerifiedEmail :: SqlExpr (Value DBUserId) -> SqlExpr (Value Bool)
|
||||||
|
hasVerifiedEmail userId = not_ $ isNothing $ subSelect $ do
|
||||||
|
emails <- from $ table @DBEmail
|
||||||
|
where_ $ emails ^. DBEmailUid ==. userId &&. isVerified emails
|
||||||
|
pure $ val True -- This is not used anywhere, there just isn't a PersistField instance for ()
|
||||||
|
|
||||||
isApplicant :: SqlExpr (Entity DBUser) -> SqlExpr (Value Bool)
|
isApplicant :: SqlExpr (Entity DBUser) -> SqlExpr (Value Bool)
|
||||||
isApplicant user = isNothing (user ^. DBUserAccepted) &&. not_ (isNothing (user ^. DBUserEmail)) &&. isNothing (user ^. DBUserRejected)
|
isApplicant user = isNothing (user ^. DBUserAccepted)
|
||||||
|
&&. hasVerifiedEmail (user ^. DBUserId)
|
||||||
|
&&. isNothing (user ^. DBUserRejected)
|
||||||
|
|
||||||
isMember :: SqlExpr (Entity DBUser) -> SqlExpr (Value Bool)
|
isMember :: SqlExpr (Entity DBUser) -> SqlExpr (Value Bool)
|
||||||
isMember user = not_ (isNothing (user ^. DBUserAccepted)) &&. isNothing (user ^. DBUserSeceded)
|
isMember user = not_ (isNothing (user ^. DBUserAccepted)) &&. isNothing (user ^. DBUserSeceded)
|
||||||
|
|
||||||
verifyEmailSecret :: MonadDB m => Text -> m Int64
|
verifyEmailSecret :: MonadDB m => Text -> m Integer
|
||||||
verifyEmailSecret secret = runQuery $ updateCount $ \user -> do
|
verifyEmailSecret secret = fmap fromIntegral $ runQuery $ updateCount $ \email -> do
|
||||||
set user [ DBUserEmailVerificationSecret =. val Nothing
|
set email [DBEmailVerificationSecret =. val Nothing]
|
||||||
, DBUserEmail =. user ^. DBUserPendingEmail
|
where_ $ email ^. DBEmailVerificationSecret ==. val (Just secret)
|
||||||
, DBUserPendingEmail =. val Nothing
|
|
||||||
]
|
getUserEmail' :: MonadDB m => UserID -> Bool -> m (Maybe (Entity DBEmail))
|
||||||
where_ $ user ^. DBUserEmailVerificationSecret ==. just (val secret)
|
getUserEmail' user verified = fmap listToMaybe $ runQuery $ select $ do
|
||||||
|
email <- from $ table @DBEmail
|
||||||
|
where_ $ email ^. DBEmailUid ==. val (toDBKey user)
|
||||||
|
&&. isNothing (email ^. DBEmailVerificationSecret) ==. val verified
|
||||||
|
pure email
|
||||||
|
|
||||||
|
getUserEmail :: MonadDB m => UserID -> m (Maybe (Entity DBEmail))
|
||||||
|
getUserEmail user = getUserEmail' user True
|
||||||
|
|
||||||
|
getUserPendingEmail :: MonadDB m => UserID -> m (Maybe (Entity DBEmail))
|
||||||
|
getUserPendingEmail user = getUserEmail' user False
|
||||||
|
|
||||||
|
addEmail :: MonadDB m => DBEmail -> m (Key DBEmail)
|
||||||
|
addEmail = runQuery . insert
|
||||||
|
|
||||||
|
updateEmail :: MonadDB m => UserID -> Email -> Text -> m (Key DBEmail)
|
||||||
|
updateEmail user email secret = runQuery $ do
|
||||||
|
delete $ do
|
||||||
|
dbEmail <- from $ table @DBEmail
|
||||||
|
where_ $ dbEmail ^. DBEmailUid ==. val (toDBKey user) &&. not_ (isVerified dbEmail)
|
||||||
|
verifiedEmail <- fmap listToMaybe $ select $ do
|
||||||
|
dbEmail <- from $ table @DBEmail
|
||||||
|
where_ $ dbEmail ^. DBEmailUid ==. val (toDBKey user)
|
||||||
|
&&. dbEmail ^. DBEmailEmail ==. val email
|
||||||
|
pure dbEmail
|
||||||
|
case verifiedEmail of
|
||||||
|
Just (Entity key _) -> pure key
|
||||||
|
Nothing -> insert DBEmail
|
||||||
|
{ dBEmailUid = toDBKey user
|
||||||
|
, dBEmailEmail = email
|
||||||
|
, dBEmailVerificationSecret = Just secret
|
||||||
|
}
|
||||||
|
|
||||||
markAsAccepted :: MonadDB m => UserID -> Time -> m ()
|
markAsAccepted :: MonadDB m => UserID -> Time -> m ()
|
||||||
markAsAccepted userID time = runQuery $ update $ \user -> do
|
markAsAccepted userID time = runQuery $ update $ \user -> do
|
||||||
|
|
Loading…
Reference in New Issue