package auth import ( "time" "git.mstar.dev/mstar/goutils/other" "git.mstar.dev/mstar/goutils/sliceutils" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "gorm.io/gorm/clause" "git.mstar.dev/mstar/linstrom/storage-new" "git.mstar.dev/mstar/linstrom/storage-new/dbgen" "git.mstar.dev/mstar/linstrom/storage-new/models" ) type LoginNextState uint8 const ( LoginNextFailure LoginNextState = 0 // Login failed (default state) LoginNextSucess LoginNextState = 1 << iota // Login suceeded LoginUnknown // Unknown login method type, should result in failure LoginNext2FaTotp // Login requires a totp token next as 2fa response LoginNext2FaPasskey // Login requires a passkey token next as 2fa response LoginNext2FaMail // Login requires an email token next as 2fa response LoginStartPassword // Login starts with a password LoginStartPasskey // Login starts with a passkey ) func ConvertNewStorageAuthMethodsToLoginState( methods []models.AuthenticationMethodType, isStart bool, ) LoginNextState { translatedMethods := sliceutils.Map(methods, oneStorageAuthToLoginState) // Filter out only the valid methods for the current request valids := sliceutils.Filter(translatedMethods, func(t LoginNextState) bool { if isStart { return t == LoginStartPasskey || t == LoginStartPassword } else { return t == LoginNext2FaTotp || t == LoginNext2FaPasskey || t == LoginNext2FaMail } }) // And then compact them down into one bit flag return sliceutils.Compact( valids, func(acc, next LoginNextState) LoginNextState { return acc | next }, ) } func oneStorageAuthToLoginState(in models.AuthenticationMethodType) LoginNextState { switch in { case models.AuthMethodGAuth: return LoginNext2FaTotp case models.AuthMethodMail: return LoginNext2FaMail case models.AuthMethodPasskey: return LoginStartPasskey case models.AuthMethodPasskey2fa: return LoginNext2FaPasskey case models.AuthMethodPassword: return LoginStartPassword default: return LoginUnknown } } func hashPassword(password string) ([]byte, error) { return bcrypt.GenerateFromPassword([]byte(password), 14) } func comparePassword(password string, hash []byte) bool { return bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil } // Start a login process with a username (NOT account ID) and password // Returns the next state, a token corresponding to that state and error // Token will be empty on failure, error describes the reason for the // failure func (a *Authenticator) StartPasswordLogin( username string, password string, ) (nextState LoginNextState, token string, err error) { if ok, err := a.canUsernameLogin(username); !ok { return LoginNextFailure, "", other.Error("auth", "user may not login", err) } acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First() switch err { case nil: break case gorm.ErrRecordNotFound: return LoginNextFailure, "", ErrInvalidCombination } var method *models.UserAuthMethod for _, authMethod := range acc.AuthMethods { if oneStorageAuthToLoginState(authMethod.AuthMethod) == LoginStartPassword { method = &authMethod break } } if method == nil { return LoginNextFailure, "", ErrUnsupportedAuthMethod } if !comparePassword(password, method.Token) { return LoginNextFailure, "", ErrInvalidCombination } nextStates := ConvertNewStorageAuthMethodsToLoginState( sliceutils.Map( acc.AuthMethods, func(t models.UserAuthMethod) models.AuthenticationMethodType { return t.AuthMethod }, ), false, ) // Catch unknown login methods if nextStates&LoginUnknown == LoginUnknown { return LoginNextFailure, "", ErrUnknownAuthMethod } if nextStates == LoginNextFailure { // Login ok, no 2fa needed // Create a new token. Don't generate the token itself, let postgres handle that 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 } // TODO: Generate login process token loginToken := models.LoginProcessToken{ User: *acc, UserId: acc.ID, ExpiresAt: calcAccessExpirationTimestamp(), Token: uuid.NewString(), } err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}). Create(&loginToken) if err != nil { return LoginNextFailure, "", other.Error( "auth", "failed to create login process token", err, ) } return nextStates, loginToken.Token, nil } func (a *Authenticator) canUsernameLogin(username string) (bool, error) { acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First() if err != nil { return false, err } if !acc.FinishedRegistration { return false, ErrCantLogin } // TODO: Check roles too finalRole := storage.CollapseRolesIntoOne( sliceutils.Map(acc.Roles, func(t models.UserToRole) models.Role { return t.Role })...) if finalRole.CanLogin != nil && !*finalRole.CanLogin { return false, ErrCantLogin } return true, nil }