164 lines
4.9 KiB
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
|
|
}
|