Auth stuff
Some checks are pending
/ test (push) Waiting to run

Password login & registration
Passkey login

May not be functional yet
This commit is contained in:
Melody Becker 2025-03-31 15:32:50 +02:00
parent b84461d0e7
commit a9af73b557
Signed by: mstar
SSH key fingerprint: SHA256:9VAo09aaVNTWKzPW7Hq2LW+ox9OdwmTSHRoD4mlz1yI
3 changed files with 159 additions and 80 deletions

View file

@ -9,15 +9,96 @@ package auth
import ( import (
"time" "time"
"git.mstar.dev/mstar/goutils/sliceutils"
"github.com/go-webauthn/webauthn/webauthn" "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 { type Authenticator struct {
webauthn *webauthn.WebAuthn 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 { func calcAccessExpirationTimestamp() time.Time {
// For now, the default expiration is one month after creation // For now, the default expiration is one month after creation
// though "never" might also be a good option // though "never" might also be a good option
return time.Now().Add(time.Hour * 24 * 30) 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
}

View file

@ -14,34 +14,42 @@ import (
"git.mstar.dev/mstar/linstrom/storage-new/models" "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 { 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() acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
if err != nil { if err != nil {
return nil, err return nil, 0, other.Error("auth", "failed to acquire user for login", err)
} }
wrappedAcc := fakeUser{acc} wrappedAcc := fakeUser{acc}
options, session, err := a.webauthn.BeginLogin(&wrappedAcc) options, session, err := a.webauthn.BeginLogin(&wrappedAcc)
if err != nil { if err != nil {
return nil, err return nil, 0, other.Error("auth", "failed to initiate passkey login", err)
} }
pkeySession := models.LoginProcessToken{ pkeySession := models.LoginProcessToken{
User: *acc, User: *acc,
UserId: acc.ID, UserId: acc.ID,
ExpiresAt: time.Now().Add(time.Minute * 3), ExpiresAt: time.Now().Add(time.Minute * 3),
Token: string(other.Must(json.Marshal(session))), Token: string(other.Must(json.Marshal(session))),
Name: "",
} }
err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).Create(&pkeySession) err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).Create(&pkeySession)
if err != nil { 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( func (a *Authenticator) CompletePasskeyLogin(
username string, username string,
sessionId uint64,
response *http.Request, response *http.Request,
) (accessToken string, err error) { ) (accessToken string, err error) {
// Get user in question // 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) return "", other.Error("auth", "failed to get user for passkey login completion", err)
} }
// Get latest login token data // 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() First()
if err != nil { if err != nil {
return "", other.Error( return "", other.Error(
@ -70,18 +78,33 @@ func (a *Authenticator) CompletePasskeyLogin(
} }
// Hand data to webauthn for completion // Hand data to webauthn for completion
newSession, err := a.webauthn.FinishLogin(&fakeUser{acc}, pkeySession, response) newSession, err := a.webauthn.FinishLogin(&fakeUser{acc}, pkeySession, response)
jsonSession, err := json.Marshal(newSession)
if err != nil { 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 // 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) Update(dbgen.UserAuthMethod.Token, jsonSession)
if err != nil { if err != nil {
return "", err return "", other.Error("auth", "failed to update credentials", err)
} }
// And delete the login token // 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{ dbAccessToken := models.AccessToken{
User: *acc, User: *acc,
UserId: acc.ID, UserId: acc.ID,
@ -89,13 +112,16 @@ func (a *Authenticator) CompletePasskeyLogin(
} }
err = dbgen.AccessToken.Omit(dbgen.AccessToken.Token).Create(&dbAccessToken) err = dbgen.AccessToken.Omit(dbgen.AccessToken.Token).Create(&dbAccessToken)
if err != nil { if err != nil {
return "", err return "", other.Error("auth", "failed to generate access token", err)
} }
return dbAccessToken.Token, nil 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") // p.l.Infof("begin registration")
// //
// // can we actually do not use the username at all? // // can we actually do not use the username at all?

View file

@ -10,61 +10,10 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "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/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models" "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) { func hashPassword(password string) ([]byte, error) {
return bcrypt.GenerateFromPassword([]byte(password), 14) 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 // Returns the next state, a token corresponding to that state and error
// Token will be empty on failure, error describes the reason for the // Token will be empty on failure, error describes the reason for the
// failure // failure
func (a *Authenticator) StartPasswordLogin( func (a *Authenticator) PerformPasswordLogin(
username string, username string,
password string, password string,
) (nextState LoginNextState, token string, err error) { ) (nextState LoginNextState, token string, err error) {
@ -164,23 +113,46 @@ func (a *Authenticator) StartPasswordLogin(
return nextStates, loginToken.Token, nil 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() acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
if err != nil { if err != nil {
return false, err return other.Error("auth", "failed to get user to add a password to", err)
} }
if !acc.FinishedRegistration { passwordHash, err := hashPassword(password)
return false, ErrCantLogin if err != nil {
return other.Error("auth", "failed to hash password", err)
} }
passwordMethods := sliceutils.Filter(
// TODO: Check roles too acc.AuthMethods,
finalRole := storage.CollapseRolesIntoOne( func(t models.UserAuthMethod) bool { return t.AuthMethod == models.AuthMethodPassword },
sliceutils.Map(acc.Roles, func(t models.UserToRole) models.Role { )
return t.Role if len(passwordMethods) > 0 {
})...) // TODO: Decide whether PerformPasswordRegister can also update existing passwords
if finalRole.CanLogin != nil && !*finalRole.CanLogin { // Imo yes, since an account can have at most one password set and having a separate method for updating
return false, ErrCantLogin // 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 {
return true, nil 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 nil
} }