2026-09-25 17:41:51 +08:00
|
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
module ChuSQL.Interface.Auth (
|
2026-09-25 17:41:51 +08:00
|
|
|
Credential (..),
|
|
|
|
|
defaultIterations,
|
|
|
|
|
hashPassword,
|
|
|
|
|
hashPasswordWith,
|
|
|
|
|
hashLooksValid,
|
|
|
|
|
verifyPassword,
|
|
|
|
|
SessionPolicy (..),
|
|
|
|
|
defaultSessionPolicy,
|
|
|
|
|
SessionStore,
|
|
|
|
|
newSessionStore,
|
|
|
|
|
sessionPolicy,
|
|
|
|
|
setSessionPolicy,
|
|
|
|
|
createSession,
|
2026-10-01 20:48:38 +08:00
|
|
|
createVersionedSession,
|
|
|
|
|
lookupVersionedSession,
|
2026-09-25 17:41:51 +08:00
|
|
|
lookupSession,
|
|
|
|
|
deleteSession,
|
|
|
|
|
sessionToken,
|
2026-10-02 14:53:02 +08:00
|
|
|
PayloadStore,
|
|
|
|
|
newPayloadStore,
|
|
|
|
|
payloadPolicy,
|
|
|
|
|
setPayloadPolicy,
|
|
|
|
|
createPayloadSession,
|
|
|
|
|
lookupPayload,
|
|
|
|
|
putPayload,
|
|
|
|
|
dropPayload,
|
|
|
|
|
sweepPayloads,
|
2026-09-25 17:41:51 +08:00
|
|
|
) where
|
|
|
|
|
|
|
|
|
|
import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar)
|
|
|
|
|
import Crypto.Hash (SHA256 (..))
|
|
|
|
|
import qualified Crypto.KDF.PBKDF2 as PBKDF2
|
|
|
|
|
import Crypto.Random (getRandomBytes)
|
|
|
|
|
import qualified Data.ByteString as BS
|
|
|
|
|
import Data.ByteArray (constEq)
|
|
|
|
|
import Data.ByteArray.Encoding (Base (Base16), convertFromBase, convertToBase)
|
|
|
|
|
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
|
|
|
|
|
import qualified Data.Map.Strict as Map
|
|
|
|
|
import Data.Maybe (isJust)
|
|
|
|
|
import Data.Text (Text)
|
|
|
|
|
import qualified Data.Text as T
|
|
|
|
|
import qualified Data.Text.Encoding as TE
|
|
|
|
|
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)
|
|
|
|
|
import Text.Read (readMaybe)
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- 口令哈希与会话仓库,服务端与 Web 管理端共用。
|
2026-09-25 17:41:51 +08:00
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 一条账号凭据:账号名 + 编码串
|
2026-09-25 17:41:51 +08:00
|
|
|
data Credential = Credential
|
|
|
|
|
{ credUser :: Text
|
|
|
|
|
, credEncoded :: Text
|
|
|
|
|
}
|
|
|
|
|
deriving (Show, Eq)
|
|
|
|
|
|
|
|
|
|
-- | 默认迭代次数(10 万次)
|
|
|
|
|
defaultIterations :: Int
|
|
|
|
|
defaultIterations = 100000
|
|
|
|
|
|
|
|
|
|
-- | 派生密钥长度(字节)
|
|
|
|
|
keyLength :: Int
|
|
|
|
|
keyLength = 32
|
|
|
|
|
|
|
|
|
|
-- | 盐长度(字节)
|
|
|
|
|
saltLength :: Int
|
|
|
|
|
saltLength = 16
|
|
|
|
|
|
|
|
|
|
-- | 迭代次数的上界:配置串被人改坏时不至于把自己算死
|
|
|
|
|
maxIterations :: Int
|
|
|
|
|
maxIterations = 10000000
|
|
|
|
|
|
|
|
|
|
-- | PBKDF2-HMAC-SHA256 派生
|
|
|
|
|
deriveKey :: Int -> BS.ByteString -> BS.ByteString -> BS.ByteString
|
|
|
|
|
deriveKey iters salt password =
|
|
|
|
|
PBKDF2.generate (PBKDF2.prfHMAC SHA256) (PBKDF2.Parameters iters keyLength) password salt
|
|
|
|
|
|
|
|
|
|
-- | 字节转十六进制
|
|
|
|
|
hexEncode :: BS.ByteString -> Text
|
|
|
|
|
hexEncode = TE.decodeUtf8 . convertToBase Base16
|
|
|
|
|
|
|
|
|
|
-- | 十六进制转字节(长度不对/有非法字符就给 Nothing)
|
|
|
|
|
hexDecode :: Text -> Maybe BS.ByteString
|
|
|
|
|
hexDecode t = case convertFromBase Base16 (TE.encodeUtf8 t) of
|
|
|
|
|
Left _ -> Nothing
|
|
|
|
|
Right bs -> Just bs
|
|
|
|
|
|
|
|
|
|
-- | 编码成 `pbkdf2-sha256$次数$盐$哈希`
|
|
|
|
|
encodeHash :: Int -> BS.ByteString -> BS.ByteString -> Text
|
|
|
|
|
encodeHash iters salt digest =
|
|
|
|
|
T.intercalate "$" ["pbkdf2-sha256", T.pack (show iters), hexEncode salt, hexEncode digest]
|
|
|
|
|
|
|
|
|
|
-- | 解回 (次数, 盐, 哈希),坏格式给 Nothing
|
|
|
|
|
parseHash :: Text -> Maybe (Int, BS.ByteString, BS.ByteString)
|
|
|
|
|
parseHash t = case T.splitOn "$" t of
|
|
|
|
|
[alg, itersT, saltT, digestT]
|
|
|
|
|
| alg == "pbkdf2-sha256" -> do
|
|
|
|
|
iters <- readMaybe (T.unpack itersT) :: Maybe Int
|
|
|
|
|
salt <- hexDecode saltT
|
|
|
|
|
digest <- hexDecode digestT
|
|
|
|
|
if iters < 1000 || iters > maxIterations || BS.null salt || BS.length digest /= keyLength
|
|
|
|
|
then Nothing
|
|
|
|
|
else Just (iters, salt, digest)
|
|
|
|
|
_ -> Nothing
|
|
|
|
|
|
|
|
|
|
-- | 用给定盐生成编码串(给定盐是为了能写确定性测试)
|
|
|
|
|
hashPasswordWith :: Int -> BS.ByteString -> Text -> Text
|
|
|
|
|
hashPasswordWith iters salt password =
|
|
|
|
|
encodeHash iters salt (deriveKey iters salt (TE.encodeUtf8 password))
|
|
|
|
|
|
|
|
|
|
-- | 给一个口令配随机盐,生成编码串
|
|
|
|
|
hashPassword :: Text -> IO Text
|
|
|
|
|
hashPassword password = do
|
|
|
|
|
salt <- getRandomBytes saltLength
|
|
|
|
|
pure (hashPasswordWith defaultIterations salt password)
|
|
|
|
|
|
|
|
|
|
-- | 恒定时间校验口令是否匹配
|
|
|
|
|
verifyPassword :: Text -> Text -> Bool
|
|
|
|
|
verifyPassword encoded candidate = case parseHash encoded of
|
|
|
|
|
Nothing -> False
|
|
|
|
|
Just (iters, salt, digest) -> constEq digest (deriveKey iters salt (TE.encodeUtf8 candidate))
|
|
|
|
|
|
|
|
|
|
-- | 编码串本身是否合法(不看口令)
|
|
|
|
|
hashLooksValid :: Text -> Bool
|
|
|
|
|
hashLooksValid = isJust . parseHash
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 一条会话:账号、创建/最后活动时间与账号版本号
|
2026-09-25 17:41:51 +08:00
|
|
|
data Session = Session
|
|
|
|
|
{ sessUser :: Text
|
|
|
|
|
, sessCreated :: UTCTime
|
|
|
|
|
, sessSeen :: UTCTime
|
2026-10-01 20:48:38 +08:00
|
|
|
, sessRevision :: Maybe Integer
|
2026-09-25 17:41:51 +08:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 会话过期策略:空闲上限与绝对上限
|
2026-09-25 17:41:51 +08:00
|
|
|
data SessionPolicy = SessionPolicy
|
|
|
|
|
{ spIdleSeconds :: NominalDiffTime
|
|
|
|
|
, spAbsoluteSeconds :: NominalDiffTime
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
-- | 默认:空闲 8 小时、绝对 24 小时
|
|
|
|
|
defaultSessionPolicy :: SessionPolicy
|
|
|
|
|
defaultSessionPolicy = SessionPolicy (8 * 3600) (24 * 3600)
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 会话仓库:令牌表、时钟与策略
|
2026-09-25 17:41:51 +08:00
|
|
|
data SessionStore = SessionStore
|
|
|
|
|
{ storeMap :: MVar (Map.Map Text Session)
|
|
|
|
|
, storeClock :: IO UTCTime
|
|
|
|
|
, storePolicy :: IORef SessionPolicy
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
-- | 造一个空仓库
|
|
|
|
|
newSessionStore :: IO UTCTime -> SessionPolicy -> IO SessionStore
|
|
|
|
|
newSessionStore clock policy = do
|
|
|
|
|
ref <- newMVar Map.empty
|
|
|
|
|
policyRef <- newIORef policy
|
|
|
|
|
pure (SessionStore ref clock policyRef)
|
|
|
|
|
|
|
|
|
|
-- | 现在用的是哪套过期策略
|
|
|
|
|
sessionPolicy :: SessionStore -> IO SessionPolicy
|
|
|
|
|
sessionPolicy = readIORef . storePolicy
|
|
|
|
|
|
|
|
|
|
-- | 换一套过期策略(设置页热改)
|
|
|
|
|
setSessionPolicy :: SessionStore -> SessionPolicy -> IO ()
|
|
|
|
|
setSessionPolicy store = writeIORef (storePolicy store)
|
|
|
|
|
|
|
|
|
|
-- | 这条会话过期了吗
|
|
|
|
|
expired :: UTCTime -> SessionPolicy -> Session -> Bool
|
|
|
|
|
expired now pol s =
|
|
|
|
|
diffUTCTime now (sessSeen s) > spIdleSeconds pol
|
|
|
|
|
|| diffUTCTime now (sessCreated s) > spAbsoluteSeconds pol
|
|
|
|
|
|
|
|
|
|
-- | 清掉过期会话
|
|
|
|
|
prune :: UTCTime -> SessionPolicy -> Map.Map Text Session -> Map.Map Text Session
|
|
|
|
|
prune now pol = Map.filter (not . expired now pol)
|
|
|
|
|
|
|
|
|
|
-- | 随机令牌:32 字节,十六进制 64 个字符
|
|
|
|
|
sessionToken :: IO Text
|
|
|
|
|
sessionToken = hexEncode <$> getRandomBytes 32
|
|
|
|
|
|
|
|
|
|
-- | 开一条会话,返回令牌
|
|
|
|
|
createSession :: SessionStore -> Text -> IO Text
|
2026-10-01 20:48:38 +08:00
|
|
|
createSession store user = createVersionedSession store user Nothing
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 开一条会话并记下账号版本号,返回令牌
|
2026-10-01 20:48:38 +08:00
|
|
|
createVersionedSession :: SessionStore -> Text -> Maybe Integer -> IO Text
|
|
|
|
|
createVersionedSession store user revision = do
|
2026-09-25 17:41:51 +08:00
|
|
|
token <- sessionToken
|
|
|
|
|
now <- storeClock store
|
|
|
|
|
pol <- sessionPolicy store
|
|
|
|
|
modifyMVar_ (storeMap store) $ \m ->
|
2026-10-01 20:48:38 +08:00
|
|
|
pure (Map.insert token (Session user now now revision) (prune now pol m))
|
2026-09-25 17:41:51 +08:00
|
|
|
pure token
|
|
|
|
|
|
|
|
|
|
-- | 查令牌并刷新最后活动时间
|
|
|
|
|
lookupSession :: SessionStore -> Text -> IO (Maybe Text)
|
2026-10-01 20:48:38 +08:00
|
|
|
lookupSession store token = fmap (fmap fst) (lookupVersionedSession store token)
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 查令牌拿回账号名与账号版本号,并刷新最后活动时间
|
2026-10-01 20:48:38 +08:00
|
|
|
lookupVersionedSession :: SessionStore -> Text -> IO (Maybe (Text, Maybe Integer))
|
|
|
|
|
lookupVersionedSession store token = do
|
2026-09-25 17:41:51 +08:00
|
|
|
now <- storeClock store
|
|
|
|
|
pol <- sessionPolicy store
|
|
|
|
|
modifyMVar (storeMap store) $ \m -> do
|
|
|
|
|
let kept = prune now pol m
|
|
|
|
|
pure $ case Map.lookup token kept of
|
|
|
|
|
Nothing -> (kept, Nothing)
|
2026-10-01 20:48:38 +08:00
|
|
|
Just s -> (Map.insert token s{sessSeen = now} kept, Just (sessUser s, sessRevision s))
|
2026-09-25 17:41:51 +08:00
|
|
|
|
|
|
|
|
-- | 删掉一条会话(退出登录)
|
|
|
|
|
deleteSession :: SessionStore -> Text -> IO ()
|
|
|
|
|
deleteSession store token = modifyMVar_ (storeMap store) (pure . Map.delete token)
|
2026-10-02 14:53:02 +08:00
|
|
|
|
|
|
|
|
-- | 一条带载荷的会话:账号、时间与载荷
|
|
|
|
|
data StoredSession a = StoredSession
|
|
|
|
|
{ psUser :: Text
|
|
|
|
|
, psCreated :: UTCTime
|
|
|
|
|
, psSeen :: UTCTime
|
|
|
|
|
, psPayload :: a
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
-- | 带载荷的会话仓库:令牌表、时钟与策略
|
|
|
|
|
data PayloadStore a = PayloadStore
|
|
|
|
|
{ psStoreMap :: MVar (Map.Map Text (StoredSession a))
|
|
|
|
|
, psClock :: IO UTCTime
|
|
|
|
|
, psPolicy :: IORef SessionPolicy
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
-- | 造一个空的带载荷仓库
|
|
|
|
|
newPayloadStore :: IO UTCTime -> SessionPolicy -> IO (PayloadStore a)
|
|
|
|
|
newPayloadStore clock policy = do
|
|
|
|
|
ref <- newMVar Map.empty
|
|
|
|
|
policyRef <- newIORef policy
|
|
|
|
|
pure (PayloadStore ref clock policyRef)
|
|
|
|
|
|
|
|
|
|
-- | 现在用的是哪套过期策略
|
|
|
|
|
payloadPolicy :: PayloadStore a -> IO SessionPolicy
|
|
|
|
|
payloadPolicy = readIORef . psPolicy
|
|
|
|
|
|
|
|
|
|
-- | 换一套过期策略
|
|
|
|
|
setPayloadPolicy :: PayloadStore a -> SessionPolicy -> IO ()
|
|
|
|
|
setPayloadPolicy store = writeIORef (psPolicy store)
|
|
|
|
|
|
|
|
|
|
-- | 这条带载荷的会话过期了吗
|
|
|
|
|
payloadExpired :: UTCTime -> SessionPolicy -> StoredSession a -> Bool
|
|
|
|
|
payloadExpired now pol s =
|
|
|
|
|
diffUTCTime now (psSeen s) > spIdleSeconds pol
|
|
|
|
|
|| diffUTCTime now (psCreated s) > spAbsoluteSeconds pol
|
|
|
|
|
|
|
|
|
|
-- | 清掉过期的带载荷会话
|
|
|
|
|
prunePayloads :: UTCTime -> SessionPolicy -> Map.Map Text (StoredSession a) -> Map.Map Text (StoredSession a)
|
|
|
|
|
prunePayloads now pol = Map.filter (not . payloadExpired now pol)
|
|
|
|
|
|
|
|
|
|
-- | 上线一条带载荷的会话,返回令牌
|
|
|
|
|
createPayloadSession :: PayloadStore a -> Text -> a -> IO Text
|
|
|
|
|
createPayloadSession store user payload = do
|
|
|
|
|
token <- sessionToken
|
|
|
|
|
now <- psClock store
|
|
|
|
|
pol <- payloadPolicy store
|
|
|
|
|
modifyMVar_ (psStoreMap store) $ \m ->
|
|
|
|
|
pure (Map.insert token (StoredSession user now now payload) (prunePayloads now pol m))
|
|
|
|
|
pure token
|
|
|
|
|
|
|
|
|
|
-- | 查令牌并刷新最后活动时间,拿回账号名与载荷
|
|
|
|
|
lookupPayload :: PayloadStore a -> Text -> IO (Maybe (Text, a))
|
|
|
|
|
lookupPayload store token = do
|
|
|
|
|
now <- psClock store
|
|
|
|
|
pol <- payloadPolicy store
|
|
|
|
|
modifyMVar (psStoreMap store) $ \m -> do
|
|
|
|
|
let kept = prunePayloads now pol m
|
|
|
|
|
pure $ case Map.lookup token kept of
|
|
|
|
|
Nothing -> (kept, Nothing)
|
|
|
|
|
Just s -> (Map.insert token s{psSeen = now} kept, Just (psUser s, psPayload s))
|
|
|
|
|
|
|
|
|
|
-- | 换掉载荷;会话已没了就返回 False
|
|
|
|
|
putPayload :: PayloadStore a -> Text -> a -> IO Bool
|
|
|
|
|
putPayload store token payload = do
|
|
|
|
|
now <- psClock store
|
|
|
|
|
pol <- payloadPolicy store
|
|
|
|
|
modifyMVar (psStoreMap store) $ \m -> do
|
|
|
|
|
let kept = prunePayloads now pol m
|
|
|
|
|
pure $ case Map.lookup token kept of
|
|
|
|
|
Nothing -> (kept, False)
|
|
|
|
|
Just s -> (Map.insert token s{psSeen = now, psPayload = payload} kept, True)
|
|
|
|
|
|
|
|
|
|
-- | 摘掉会话并把载荷交还
|
|
|
|
|
dropPayload :: PayloadStore a -> Text -> IO (Maybe a)
|
|
|
|
|
dropPayload store token = do
|
|
|
|
|
now <- psClock store
|
|
|
|
|
pol <- payloadPolicy store
|
|
|
|
|
modifyMVar (psStoreMap store) $ \m -> do
|
|
|
|
|
let kept = prunePayloads now pol m
|
|
|
|
|
pure $ case Map.lookup token kept of
|
|
|
|
|
Nothing -> (kept, Nothing)
|
|
|
|
|
Just s -> (Map.delete token kept, Just (psPayload s))
|
|
|
|
|
|
|
|
|
|
-- | 清掉过期会话并把它们的载荷交还;调用者负责关掉这些载荷
|
|
|
|
|
sweepPayloads :: PayloadStore a -> IO [a]
|
|
|
|
|
sweepPayloads store = do
|
|
|
|
|
now <- psClock store
|
|
|
|
|
pol <- payloadPolicy store
|
|
|
|
|
modifyMVar (psStoreMap store) $ \m -> do
|
|
|
|
|
let (gone, kept) = Map.partition (payloadExpired now pol) m
|
|
|
|
|
pure (kept, map psPayload (Map.elems gone))
|