mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 10:28:27 +00:00
Prepare remember-me authentication variant
This commit is contained in:
@ -1,24 +1,21 @@
|
||||
package docspell.backend.auth
|
||||
|
||||
import java.time.Instant
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.Common
|
||||
import docspell.backend.auth.AuthToken._
|
||||
import docspell.common._
|
||||
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String) {
|
||||
def asString = s"$millis-${b64enc(account.asString)}-$salt-$sig"
|
||||
case class AuthToken(nowMillis: Long, account: AccountId, salt: String, sig: String) {
|
||||
def asString = s"$nowMillis-${TokenUtil.b64enc(account.asString)}-$salt-$sig"
|
||||
|
||||
def sigValid(key: ByteVector): Boolean = {
|
||||
val newSig = AuthToken.sign(this, key)
|
||||
AuthToken.constTimeEq(sig, newSig)
|
||||
val newSig = TokenUtil.sign(this, key)
|
||||
TokenUtil.constTimeEq(sig, newSig)
|
||||
}
|
||||
def sigInvalid(key: ByteVector): Boolean =
|
||||
!sigValid(key)
|
||||
@ -27,7 +24,7 @@ case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String
|
||||
!isExpired(validity)
|
||||
|
||||
def isExpired(validity: Duration): Boolean = {
|
||||
val ends = Instant.ofEpochMilli(millis).plusMillis(validity.millis)
|
||||
val ends = Instant.ofEpochMilli(nowMillis).plusMillis(validity.millis)
|
||||
Instant.now.isAfter(ends)
|
||||
}
|
||||
|
||||
@ -36,14 +33,13 @@ case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String
|
||||
}
|
||||
|
||||
object AuthToken {
|
||||
private val utf8 = java.nio.charset.StandardCharsets.UTF_8
|
||||
|
||||
def fromString(s: String): Either[String, AuthToken] =
|
||||
s.split("\\-", 4) match {
|
||||
case Array(ms, as, salt, sig) =>
|
||||
for {
|
||||
millis <- asInt(ms).toRight("Cannot read authenticator data")
|
||||
acc <- b64dec(as).toRight("Cannot read authenticator data")
|
||||
millis <- TokenUtil.asInt(ms).toRight("Cannot read authenticator data")
|
||||
acc <- TokenUtil.b64dec(as).toRight("Cannot read authenticator data")
|
||||
accId <- AccountId.parse(acc)
|
||||
} yield AuthToken(millis, accId, salt, sig)
|
||||
|
||||
@ -56,27 +52,8 @@ object AuthToken {
|
||||
salt <- Common.genSaltString[F]
|
||||
millis = Instant.now.toEpochMilli
|
||||
cd = AuthToken(millis, accountId, salt, "")
|
||||
sig = sign(cd, key)
|
||||
sig = TokenUtil.sign(cd, key)
|
||||
} yield cd.copy(sig = sig)
|
||||
|
||||
private def sign(cd: AuthToken, key: ByteVector): String = {
|
||||
val raw = cd.millis.toString + cd.account.asString + cd.salt
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
|
||||
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64
|
||||
}
|
||||
|
||||
private def b64enc(s: String): String =
|
||||
ByteVector.view(s.getBytes(utf8)).toBase64
|
||||
|
||||
private def b64dec(s: String): Option[String] =
|
||||
ByteVector.fromValidBase64(s).decodeUtf8.toOption
|
||||
|
||||
private def asInt(s: String): Option[Long] =
|
||||
Either.catchNonFatal(s.toLong).toOption
|
||||
|
||||
private def constTimeEq(s1: String, s2: String): Boolean =
|
||||
s1.zip(s2)
|
||||
.foldLeft(true)({ case (r, (c1, c2)) => r & c1 == c2 }) & s1.length == s2.length
|
||||
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package docspell.backend.auth
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import cats.data.OptionT
|
||||
|
||||
import docspell.backend.auth.Login._
|
||||
import docspell.common._
|
||||
@ -19,14 +20,27 @@ trait Login[F[_]] {
|
||||
|
||||
def loginUserPass(config: Config)(up: UserPass): F[Result]
|
||||
|
||||
def loginRememberMe(config: Config)(token: Ident): F[Result]
|
||||
|
||||
def loginSessionOrRememberMe(
|
||||
config: Config
|
||||
)(sessionKey: String, rememberId: Option[Ident]): F[Result]
|
||||
}
|
||||
|
||||
object Login {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
case class Config(serverSecret: ByteVector, sessionValid: Duration)
|
||||
case class Config(
|
||||
serverSecret: ByteVector,
|
||||
sessionValid: Duration,
|
||||
rememberMe: RememberMe
|
||||
)
|
||||
|
||||
case class UserPass(user: String, pass: String) {
|
||||
case class RememberMe(enabled: Boolean, valid: Duration) {
|
||||
val disabled = !enabled
|
||||
}
|
||||
|
||||
case class UserPass(user: String, pass: String, rememberMe: Boolean) {
|
||||
def hidePass: UserPass =
|
||||
if (pass.isEmpty) copy(pass = "<none>")
|
||||
else copy(pass = "***")
|
||||
@ -81,12 +95,51 @@ object Login {
|
||||
Result.invalidAuth.pure[F]
|
||||
}
|
||||
|
||||
def loginRememberMe(config: Config)(token: Ident): F[Result] = {
|
||||
def okResult(acc: AccountId) =
|
||||
store.transact(RUser.updateLogin(acc)) *>
|
||||
AuthToken.user(acc, config.serverSecret).map(Result.ok)
|
||||
|
||||
if (config.rememberMe.disabled) Result.invalidAuth.pure[F]
|
||||
else
|
||||
(for {
|
||||
now <- OptionT.liftF(Timestamp.current[F])
|
||||
minTime = now - config.rememberMe.valid
|
||||
data <- OptionT(store.transact(QLogin.findByRememberMe(token, minTime).value))
|
||||
_ <- OptionT.liftF(
|
||||
Sync[F].delay(logger.info(s"Account lookup via remember me: $data"))
|
||||
)
|
||||
res <- OptionT.liftF(
|
||||
if (checkNoPassword(data)) okResult(data.account)
|
||||
else Result.invalidAuth.pure[F]
|
||||
)
|
||||
} yield res).getOrElse(Result.invalidAuth)
|
||||
}
|
||||
|
||||
def loginSessionOrRememberMe(
|
||||
config: Config
|
||||
)(sessionKey: String, rememberId: Option[Ident]): F[Result] =
|
||||
loginSession(config)(sessionKey).flatMap {
|
||||
case success @ Result.Ok(_) => (success: Result).pure[F]
|
||||
case fail =>
|
||||
rememberId match {
|
||||
case Some(rid) =>
|
||||
loginRememberMe(config)(rid)
|
||||
case None =>
|
||||
fail.pure[F]
|
||||
}
|
||||
}
|
||||
|
||||
private def check(given: String)(data: QLogin.Data): Boolean = {
|
||||
val passOk = BCrypt.checkpw(given, data.password.pass)
|
||||
checkNoPassword(data) && passOk
|
||||
}
|
||||
|
||||
private def checkNoPassword(data: QLogin.Data): Boolean = {
|
||||
val collOk = data.collectiveState == CollectiveState.Active ||
|
||||
data.collectiveState == CollectiveState.ReadOnly
|
||||
val userOk = data.userState == UserState.Active
|
||||
val passOk = BCrypt.checkpw(given, data.password.pass)
|
||||
collOk && userOk && passOk
|
||||
collOk && userOk
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -0,0 +1,58 @@
|
||||
package docspell.backend.auth
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.Common
|
||||
import docspell.common._
|
||||
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
case class RememberToken(nowMillis: Long, rememberId: Ident, salt: String, sig: String) {
|
||||
def asString = s"$nowMillis-${TokenUtil.b64enc(rememberId.id)}-$salt-$sig"
|
||||
|
||||
def sigValid(key: ByteVector): Boolean = {
|
||||
val newSig = TokenUtil.sign(this, key)
|
||||
TokenUtil.constTimeEq(sig, newSig)
|
||||
}
|
||||
def sigInvalid(key: ByteVector): Boolean =
|
||||
!sigValid(key)
|
||||
|
||||
def notExpired(validity: Duration): Boolean =
|
||||
!isExpired(validity)
|
||||
|
||||
def isExpired(validity: Duration): Boolean = {
|
||||
val ends = Instant.ofEpochMilli(nowMillis).plusMillis(validity.millis)
|
||||
Instant.now.isAfter(ends)
|
||||
}
|
||||
|
||||
def validate(key: ByteVector, validity: Duration): Boolean =
|
||||
sigValid(key) && notExpired(validity)
|
||||
}
|
||||
|
||||
object RememberToken {
|
||||
|
||||
def fromString(s: String): Either[String, RememberToken] =
|
||||
s.split("\\-", 4) match {
|
||||
case Array(ms, as, salt, sig) =>
|
||||
for {
|
||||
millis <- TokenUtil.asInt(ms).toRight("Cannot read authenticator data")
|
||||
rId <- TokenUtil.b64dec(as).toRight("Cannot read authenticator data")
|
||||
accId <- Ident.fromString(rId)
|
||||
} yield RememberToken(millis, accId, salt, sig)
|
||||
|
||||
case _ =>
|
||||
Left("Invalid authenticator")
|
||||
}
|
||||
|
||||
def user[F[_]: Sync](rememberId: Ident, key: ByteVector): F[RememberToken] =
|
||||
for {
|
||||
salt <- Common.genSaltString[F]
|
||||
millis = Instant.now.toEpochMilli
|
||||
cd = RememberToken(millis, rememberId, salt, "")
|
||||
sig = TokenUtil.sign(cd, key)
|
||||
} yield cd.copy(sig = sig)
|
||||
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package docspell.backend.auth
|
||||
|
||||
import scodec.bits._
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
import cats.implicits._
|
||||
|
||||
private[auth] object TokenUtil {
|
||||
private val utf8 = java.nio.charset.StandardCharsets.UTF_8
|
||||
|
||||
def sign(cd: RememberToken, key: ByteVector): String = {
|
||||
val raw = cd.nowMillis.toString + cd.rememberId.id + cd.salt
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
|
||||
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64
|
||||
}
|
||||
|
||||
def sign(cd: AuthToken, key: ByteVector): String = {
|
||||
val raw = cd.nowMillis.toString + cd.account.asString + cd.salt
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
|
||||
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64
|
||||
}
|
||||
|
||||
def b64enc(s: String): String =
|
||||
ByteVector.view(s.getBytes(utf8)).toBase64
|
||||
|
||||
def b64dec(s: String): Option[String] =
|
||||
ByteVector.fromValidBase64(s).decodeUtf8.toOption
|
||||
|
||||
def asInt(s: String): Option[Long] =
|
||||
Either.catchNonFatal(s.toLong).toOption
|
||||
|
||||
def constTimeEq(s1: String, s2: String): Boolean =
|
||||
s1.zip(s2)
|
||||
.foldLeft(true)({ case (r, (c1, c2)) => r & c1 == c2 }) & s1.length == s2.length
|
||||
|
||||
}
|
Reference in New Issue
Block a user