From a9af73b557d2d32412c0713041789d5b1f6b7059 Mon Sep 17 00:00:00 2001 From: mstar Date: Mon, 31 Mar 2025 15:32:50 +0200 Subject: [PATCH] Auth stuff Password login & registration Passkey login May not be functional yet --- auth-new/auth.go | 81 ++++++++++++++++++++++ auth-new/passkey.go | 54 +++++++++++---- auth-new/{login.go => password.go} | 104 +++++++++++------------------ 3 files changed, 159 insertions(+), 80 deletions(-) rename auth-new/{login.go => password.go} (58%) diff --git a/auth-new/auth.go b/auth-new/auth.go index c05c377..6bc9576 100644 --- a/auth-new/auth.go +++ b/auth-new/auth.go @@ -9,15 +9,96 @@ package auth import ( "time" + "git.mstar.dev/mstar/goutils/sliceutils" "github.com/go-webauthn/webauthn/webauthn" + + "git.mstar.dev/mstar/linstrom/storage-new" + "git.mstar.dev/mstar/linstrom/storage-new/dbgen" + "git.mstar.dev/mstar/linstrom/storage-new/models" ) type Authenticator struct { webauthn *webauthn.WebAuthn } +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 calcAccessExpirationTimestamp() time.Time { // For now, the default expiration is one month after creation // though "never" might also be a good option return time.Now().Add(time.Hour * 24 * 30) } + +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 + } +} + +// Check whether a given username can log in. +// It only provides a yes/no answer and an error in case some check failed +// due to unforseen circumstances. Though the error will always be ErrCantLogin +// in case the known information (excluding database or other failures) prevents +// a login +// +// TODO: Decide whether to include the reason for disallowed login +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 + } + + 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 +} diff --git a/auth-new/passkey.go b/auth-new/passkey.go index 2505aa3..9d1ccc2 100644 --- a/auth-new/passkey.go +++ b/auth-new/passkey.go @@ -14,34 +14,42 @@ import ( "git.mstar.dev/mstar/linstrom/storage-new/models" ) -func (a *Authenticator) StartPasskeyLogin(username string) (*protocol.CredentialAssertion, error) { +// Start the login process via passkey for a given username. +// Returns the credential options the passkey needs to sign +func (a *Authenticator) StartPasskeyLogin( + username string, +) (*protocol.CredentialAssertion, uint64, error) { if ok, err := a.canUsernameLogin(username); !ok { - return nil, other.Error("auth", "user may not login", err) + return nil, 0, other.Error("auth", "user may not login", err) } acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First() if err != nil { - return nil, err + return nil, 0, other.Error("auth", "failed to acquire user for login", err) } wrappedAcc := fakeUser{acc} options, session, err := a.webauthn.BeginLogin(&wrappedAcc) if err != nil { - return nil, err + return nil, 0, other.Error("auth", "failed to initiate passkey login", err) } pkeySession := models.LoginProcessToken{ User: *acc, UserId: acc.ID, ExpiresAt: time.Now().Add(time.Minute * 3), Token: string(other.Must(json.Marshal(session))), + Name: "", } err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).Create(&pkeySession) if err != nil { - return nil, err + return nil, 0, other.Error("auth", "failed to create login process token", err) } - return options, nil + return options, pkeySession.ID, nil } +// Complete a passkey login request +// Takes the username logging in as well as the raw request containing the passkey response func (a *Authenticator) CompletePasskeyLogin( username string, + sessionId uint64, response *http.Request, ) (accessToken string, err error) { // Get user in question @@ -50,7 +58,7 @@ func (a *Authenticator) CompletePasskeyLogin( return "", other.Error("auth", "failed to get user for passkey login completion", err) } // Get latest login token data - loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.UserId.Eq(acc.ID)). + loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.ID.Eq(sessionId)). First() if err != nil { return "", other.Error( @@ -70,18 +78,33 @@ func (a *Authenticator) CompletePasskeyLogin( } // Hand data to webauthn for completion newSession, err := a.webauthn.FinishLogin(&fakeUser{acc}, pkeySession, response) - jsonSession, err := json.Marshal(newSession) if err != nil { - return "", err + return "", other.Error("auth", "passkey completion failed", err) + } + jsonSessionId, err := json.Marshal(newSession.ID) + if err != nil { + return "", other.Error("auth", "failed to marshal session", err) + } + jsonSession, err := json.Marshal(newSession.ID) + if err != nil { + return "", other.Error("auth", "failed to marshal session", err) } // Update credentials - _, err = dbgen.UserAuthMethod.Where(dbgen.UserAuthMethod.Token.Like("%"+string(jsonSession)+"%")). + // WARN: I am not sure if this will work + // Using the ID of the passkey session *should* be unique enough to identify the correct one + // Of course, even then, there's still the problem of matching as + // I can't yet guarantee that the parsed json content for the ID would be the same + _, err = dbgen.UserAuthMethod.Where(dbgen.UserAuthMethod.Token.Like("%"+string(jsonSessionId)+"%")). Update(dbgen.UserAuthMethod.Token, jsonSession) if err != nil { - return "", err + return "", other.Error("auth", "failed to update credentials", err) } // And delete the login token - dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.UserId.Eq(acc.ID)).Delete(loginToken) + _, err = dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.UserId.Eq(acc.ID)). + Delete(loginToken) + if err != nil { + return "", other.Error("auth", "failed to delete login process", err) + } dbAccessToken := models.AccessToken{ User: *acc, UserId: acc.ID, @@ -89,13 +112,16 @@ func (a *Authenticator) CompletePasskeyLogin( } err = dbgen.AccessToken.Omit(dbgen.AccessToken.Token).Create(&dbAccessToken) if err != nil { - return "", err + return "", other.Error("auth", "failed to generate access token", err) } return dbAccessToken.Token, nil } -func (a *Authenticator) StartPasskeyRegistration(username string) error { +// Start the process of registrating a passkey to an account +func (a *Authenticator) StartPasskeyRegistration( + username string, +) (*protocol.CredentialAssertion, error) { // p.l.Infof("begin registration") // // // can we actually do not use the username at all? diff --git a/auth-new/login.go b/auth-new/password.go similarity index 58% rename from auth-new/login.go rename to auth-new/password.go index 304f261..a7efa92 100644 --- a/auth-new/login.go +++ b/auth-new/password.go @@ -10,61 +10,10 @@ import ( "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) } @@ -77,7 +26,7 @@ func comparePassword(password string, hash []byte) bool { // 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( +func (a *Authenticator) PerformPasswordLogin( username string, password string, ) (nextState LoginNextState, token string, err error) { @@ -164,23 +113,46 @@ func (a *Authenticator) StartPasswordLogin( return nextStates, loginToken.Token, nil } -func (a *Authenticator) canUsernameLogin(username string) (bool, error) { +// Register a password to an account +// There is no check for whether that is allowed from the active roles. +// If the given username already has a password set, it gets updated to the new one. +// If there is no password set yet (i.e. during account registration or passkey only so far) +// it creates the password link +func (a *Authenticator) PerformPasswordRegister(username, password string) error { acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First() if err != nil { - return false, err + return other.Error("auth", "failed to get user to add a password to", err) } - if !acc.FinishedRegistration { - return false, ErrCantLogin + passwordHash, err := hashPassword(password) + if err != nil { + return other.Error("auth", "failed to hash password", err) } - - // 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 + passwordMethods := sliceutils.Filter( + acc.AuthMethods, + func(t models.UserAuthMethod) bool { return t.AuthMethod == models.AuthMethodPassword }, + ) + if len(passwordMethods) > 0 { + // TODO: Decide whether PerformPasswordRegister can also update existing passwords + // Imo yes, since an account can have at most one password set and having a separate method for updating + // would increase complexity + // For now, do perform an update + dbPass := passwordMethods[0] + _, err = dbgen.UserAuthMethod.Where(dbgen.UserAuthMethod.ID.Eq(dbPass.ID)). + Update(dbgen.UserAuthMethod.Token, passwordHash) + if err != nil { + return other.Error("auth", "failed to update password", err) + } + } else { + dbPass := models.UserAuthMethod{ + Token: passwordHash, + AuthMethod: models.AuthMethodPassword, + User: *acc, + UserId: acc.ID, + } + err = dbgen.UserAuthMethod.Omit(dbgen.UserAuthMethod.ID).Create(&dbPass) + if err != nil { + return other.Error("auth", "failed to insert password", err) + } } - - return true, nil + return nil }