Prepare remember-me authentication variant

This commit is contained in:
Eike Kettner
2020-12-03 19:45:06 +01:00
parent 07f3e08f35
commit c10c1fad72
15 changed files with 294 additions and 40 deletions

View File

@ -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
}

View File

@ -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
}
})
}

View File

@ -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)
}

View File

@ -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
}