linstrom/auth-new/totp.go

164 lines
4.9 KiB
Go

package auth
// Some helpful comments from: https://waters.me/internet/google-authenticator-implementation-note-key-length-token-reuse/
import (
"strings"
"time"
"git.mstar.dev/mstar/goutils/other"
"git.mstar.dev/mstar/goutils/sliceutils"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"gorm.io/gorm/clause"
"git.mstar.dev/mstar/linstrom/config"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
)
const totpUnverifiedSuffix = "-NOT_VERIFIED"
const totpTokenNoLongerRecentlyUsed = time.Second * 90
// Perform a 2nd factor totp based login
func (a *Authenticator) PerformTotpLogin(
username string,
sessionId uint64,
totpToken string,
) (LoginNextState, string, error) {
// First check if that token has been seen recently for that user
if timestamp, found := a.recentlyUsedTotpTokens[totpToken+"+"+username]; found {
if timestamp.Add(totpTokenNoLongerRecentlyUsed).After(time.Now()) {
return LoginNextFailure, "", ErrTotpRecentlyUsed
} else {
delete(a.recentlyUsedTotpTokens, totpToken+"+"+username)
}
}
// Then ensure user is allowed to log in
if ok, err := a.canUsernameLogin(username); !ok {
return 0, "", other.Error("auth", "user may not login", err)
}
// DO NOT fetch the account directly. Go via session id
// otherwise you could in theory log in with only the totp token
// which obviously is a huge risk with it being at most 1'000'000 unique values.
// And even with rate limiting (performed by one of the upper layers)
// this wouldn't take too long
loginSession, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.ID.Eq(sessionId)).
First()
if err != nil {
return 0, "", other.Error("auth", "no login session with this id", err)
}
acc := loginSession.User
dbSecrets := sliceutils.Filter(acc.AuthMethods, func(t models.UserAuthMethod) bool {
return t.AuthMethod == models.AuthMethodGAuth
})
encryptedSecrets := sliceutils.Map(
dbSecrets,
func(t models.UserAuthMethod) string {
return string(t.Token)
},
)
secrets := []string{}
for _, key := range encryptedSecrets {
decrypted, err := Decrypt([]byte(config.GlobalConfig.Storage.EncryptionKey), []byte(key))
if err != nil {
return 0, "", other.Error(
"auth",
"failed to decrypt secret",
&CombinedError{ErrDecryptionFailure, err},
)
}
secrets = append(secrets, string(decrypted))
}
found := false
foundIndex := -1
for i, secret := range secrets {
if totp.Validate(totpToken, secret) {
found = true
foundIndex = i
break
}
}
if !found {
return LoginNextFailure, "", other.Error(
"auth",
"no fitting credential found",
ErrInvalidCombination,
)
}
// If not verified yet, mark as verified
if strings.HasSuffix(dbSecrets[foundIndex].Name, totpUnverifiedSuffix) {
dbgen.UserAuthMethod.
Where(dbgen.UserAuthMethod.ID.
Eq(dbSecrets[foundIndex].ID)).
Update(dbgen.UserAuthMethod.Name, strings.TrimSuffix(dbSecrets[foundIndex].Name, totpUnverifiedSuffix))
}
// store this token and username combination as recently used
a.recentlyUsedTotpTokens[totpToken+"+"+username] = time.Now()
// Generate access token and return since totp would be the end station for 2fa
token := models.AccessToken{
User: acc,
UserId: acc.ID,
ExpiresAt: time.Now().Add(time.Hour * 24 * 365),
}
err = dbgen.AccessToken.
// technically, the chance of a conflict is so incredibly low that it should never ever occur
// since both the username and a randomly generated uuid would need to be created the same. Twice
// But, just in case, do this
Clauses(clause.OnConflict{DoNothing: true}).
Omit(dbgen.AccessToken.Token).
Create(&token)
if err != nil {
return LoginNextFailure, "", other.Error(
"auth",
"failed to create new access token",
err,
)
}
return LoginNextSucess, token.Token, nil
}
// Create a new totp key for a user.
// The key is marked as not verified until it is sucessfully used once
func (a *Authenticator) StartTotpRegistration(
username string,
tokenName string,
) (*otp.Key, error) {
if ok, err := a.canUsernameLogin(username); !ok {
return nil, other.Error("auth", "user may not login", err)
}
acc, err := dbgen.User.GetByUsername(username)
if err != nil {
return nil, other.Error("auth", "failed to find account", err)
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: config.GlobalConfig.General.GetFullDomain(),
AccountName: username,
SecretSize: 160,
})
if err != nil {
return nil, err
}
secret := key.Secret()
encryptedSecret, err := Encrypt(
[]byte(config.GlobalConfig.Storage.EncryptionKey),
[]byte(secret),
)
if err != nil {
return nil, other.Error("auth", "failed to encrypt secret", err)
}
authToken := models.UserAuthMethod{
UserId: acc.ID,
User: *acc,
Token: encryptedSecret,
AuthMethod: models.AuthMethodGAuth,
Name: tokenName + totpUnverifiedSuffix,
}
err = dbgen.UserAuthMethod.Create(&authToken)
if err != nil {
return nil, err
}
return key, nil
}