diff --git a/auth-new/auth.go b/auth-new/auth.go new file mode 100644 index 0000000..377ca0f --- /dev/null +++ b/auth-new/auth.go @@ -0,0 +1,9 @@ +package auth + +import ( + "github.com/go-webauthn/webauthn/webauthn" +) + +type Authenticator struct { + webauthn *webauthn.WebAuthn +} diff --git a/auth-new/errors.go b/auth-new/errors.go new file mode 100644 index 0000000..0eea820 --- /dev/null +++ b/auth-new/errors.go @@ -0,0 +1,11 @@ +package auth + +import "errors" + +var ( + // The provided authentication method is not known to the server + ErrUnknownAuthMethod = errors.New("unknown authentication method") + // The user hasn't setup the provided authentication method + ErrUnsupportedAuthMethod = errors.New("authentication method not supported for this user") + ErrInvalidCombination = errors.New("invalid account and token combination") +) diff --git a/auth-new/fakeUser.go b/auth-new/fakeUser.go new file mode 100644 index 0000000..92d156b --- /dev/null +++ b/auth-new/fakeUser.go @@ -0,0 +1,67 @@ +package auth + +import ( + "encoding/json" + + "git.mstar.dev/mstar/goutils/sliceutils" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/rs/zerolog/log" + + "git.mstar.dev/mstar/linstrom/storage-new/models" +) + +type fakeUser struct { + actualUser *models.User +} + +// Ensure that fakeUser always implements webauthn.User +var _ webauthn.User = &fakeUser{} + +// WebAuthnID provides the user handle of the user account. A user handle is an opaque byte sequence with a maximum +// size of 64 bytes, and is not meant to be displayed to the user. +// +// To ensure secure operation, authentication and authorization decisions MUST be made on the basis of this id +// member, not the displayName nor name members. See Section 6.1 of [RFC8266]. +// +// It's recommended this value is completely random and uses the entire 64 bytes. +// +// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dom-publickeycredentialuserentity-id) +func (fakeuser *fakeUser) WebAuthnID() []byte { + return fakeuser.actualUser.PasskeyId +} + +// WebAuthnName provides the name attribute of the user account during registration and is a human-palatable name for the user +// account, intended only for display. For example, "Alex Müller" or "田中倫". The Relying Party SHOULD let the user +// choose this, and SHOULD NOT restrict the choice more than necessary. +// +// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dictdef-publickeycredentialuserentity) +func (fakeuser *fakeUser) WebAuthnName() string { + return fakeuser.actualUser.DisplayName +} + +// WebAuthnDisplayName provides the name attribute of the user account during registration and is a human-palatable +// name for the user account, intended only for display. For example, "Alex Müller" or "田中倫". The Relying Party +// SHOULD let the user choose this, and SHOULD NOT restrict the choice more than necessary. +// +// Specification: §5.4.3. User Account Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialuserentity-displayname) +func (fakeuser *fakeUser) WebAuthnDisplayName() string { + return fakeuser.actualUser.DisplayName +} + +// WebAuthnCredentials provides the list of Credential objects owned by the user. +func (fakeuser *fakeUser) WebAuthnCredentials() []webauthn.Credential { + // Basically just convert the relevant entries from the user to a credential + return sliceutils.Map(sliceutils.Filter( + fakeuser.actualUser.AuthMethods, + func(t models.UserAuthMethod) bool { + return t.AuthMethod == models.AuthMethodPasskey || + t.AuthMethod == models.AuthMethodPasskey2fa + }), func(t models.UserAuthMethod) webauthn.Credential { + var c webauthn.Credential + if err := json.Unmarshal(t.Token, &c); err != nil { + // TODO: Is there any better way to handle the error here? + log.Error().Err(err).Msg("Failed to unmarshal webauthn credential") + } + return c + }) +} diff --git a/auth-new/login.go b/auth-new/login.go new file mode 100644 index 0000000..740e583 --- /dev/null +++ b/auth-new/login.go @@ -0,0 +1,161 @@ +package auth + +import ( + "time" + + "git.mstar.dev/mstar/goutils/other" + "git.mstar.dev/mstar/goutils/sliceutils" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "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) { + var acc *models.User + 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: time.Now().Add(time.Minute * 5), + } + err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{DoNothing: true}). + Omit(dbgen.LoginProcessToken.Token). + Create(&loginToken) + + if err != nil { + return LoginNextFailure, "", other.Error( + "auth", + "failed to create login process token", + err, + ) + } + + return nextStates, loginToken.Token, nil +} diff --git a/auth-new/passkey.go b/auth-new/passkey.go new file mode 100644 index 0000000..a680851 --- /dev/null +++ b/auth-new/passkey.go @@ -0,0 +1,16 @@ +package auth + +import "git.mstar.dev/mstar/linstrom/storage-new/dbgen" + +func (a *Authenticator) StartPasskeyLogin(username string) error { + acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First() + if err != nil { + return err + } + _ = acc + panic("Not implemented") // TODO: Implement me +} + +func (a *Authenticator) CompletePasskeyLogin(username string) error { + panic("Not implemented") // TODO: Implement me +}