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 }