package auth // Some helpful comments from: https://waters.me/internet/google-authenticator-implementation-note-key-length-token-reuse/ import ( "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 func (a *Authenticator) PerformTotpLogin( username string, 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) } } if ok, err := a.canUsernameLogin(username); !ok { return 0, "", other.Error("auth", "user may not login", err) } acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First() if err != nil { return LoginNextFailure, "", other.Error("auth", "failed to find account", err) } encryptedSecrets := sliceutils.Map( sliceutils.Filter(acc.AuthMethods, func(t models.UserAuthMethod) bool { return t.AuthMethod == models.AuthMethodGAuth }), 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 for _, secret := range secrets { if totp.Validate(totpToken, secret) { found = true break } } if !found { return LoginNextFailure, "", other.Error( "auth", "no fitting credential found", ErrInvalidCombination, ) } a.recentlyUsedTotpTokens[totpToken+"+"+username] = time.Now() 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 } 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.Where(dbgen.User.Username.Eq(username)).First() 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 }